Merge branch 'develop' of sloum/bombadillo into master

This commit is contained in:
Sloom Sloum Sluom IV 2019-12-01 12:09:59 -05:00 committed by Gitea
commit ce3744a22b
46 changed files with 3244 additions and 1739 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
bombadillo
*.asciinema

53
LICENSE
View File

@ -619,56 +619,3 @@ Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

71
Makefile Normal file
View File

@ -0,0 +1,71 @@
GOCMD := go
BINARY := bombadillo
PREFIX := /usr/local
EXEC_PREFIX := ${PREFIX}
BINDIR := ${EXEC_PREFIX}/bin
DATAROOTDIR := ${PREFIX}/share
MANDIR := ${DATAROOTDIR}/man
MAN1DIR := ${MANDIR}/man1
test : GOCMD := go1.11.13
# Use a dateformat rather than -I flag since OSX
# does not support -I. It also doesn't support
# %:z - so settle for %z.
BUILD_TIME := ${shell date "+%Y-%m-%dT%H:%M%z"}
# If VERSION is empty or not defined use the contents of the VERSION file
VERSION := ${shell git describe --tags 2> /dev/null}
ifndef VERSION
VERSION := ${shell cat ./VERSION}
endif
LDFLAGS := -ldflags "-s -X main.version=${VERSION} -X main.build=${BUILD_TIME}"
.PHONY: build
build:
${GOCMD} build ${LDFLAGS} -o ${BINARY}
.PHONY: install
install: install-bin install-man install-desktop clean
.PHONY: install-man
install-man: bombadillo.1
gzip -k ./bombadillo.1
install -d ${DESTDIR}${MAN1DIR}
install -m 0644 ./bombadillo.1.gz ${DESTDIR}${MAN1DIR}
.PHONY: install-desktop
install-desktop:
ifeq ($(shell uname), Linux)
# These steps will not work on Darwin, Plan9, or Windows
# They would likely work on BSD systems
install -d ${DESTDIR}${DATAROOTDIR}/applications
install -m 0644 ./bombadillo.desktop ${DESTDIR}${DATAROOTDIR}/applications
install -d ${DESTDIR}${DATAROOTDIR}/pixmaps
install -m 0644 ./bombadillo-icon.png ${DESTDIR}${DATAROOTDIR}/pixmaps
-update-desktop-database 2> /dev/null
else
@echo "* Skipping protocol handler associations and desktop file creation for non-linux system *"
endif
.PHONY: install-bin
install-bin: build
install -d ${DESTDIR}${BINDIR}
install -m 0755 ./${BINARY} ${DESTDIR}${BINDIR}
.PHONY: clean
clean:
${GOCMD} clean
rm -f ./bombadillo.1.gz 2> /dev/null
.PHONY: uninstall
uninstall: clean
rm -f ${DESTDIR}${MAN1DIR}/bombadillo.1.gz
rm -f ${DESTDIR}${BINDIR}/${BINARY}
rm -f ${DESTDIR}${DATAROOTDIR}/applications/bombadillo.desktop
rm -f ${DESTDIR}${DATAROOTDIR}/pixmaps/bombadillo-icon.png
-update-desktop-database 2> /dev/null
.PHONY: test
test: clean build

129
README.md
View File

@ -1,57 +1,142 @@
# Bombadillo
# Bombadillo - a non-web browser
Bombadillo is a modern [Gopher](https://en.wikipedia.org/wiki/Gopher_(protocol)) client for the terminal, and functions as a pager/terminal UI. Bombadillo features vim-like keybindings, configurable settings, and a robust command selection. Bombadillo is under active development.
Bombadillo is a non-web browser for the terminal.
![a screenshot of the bombadillo browser](bombadillo-screenshot.png)
Bombadillo features a full terminal user interface, vim-like keybindings, document pager, configurable settings, and a robust command selection.
Currently, Bombadillo supports the following protocols as first class citizens:
* gopher
* gemini
* finger
* local (a user's file system)
Support for the following protocols is also available via integration with 3rd party applications:
* telnet
* Links are opened in a telnet application run as a subprocess.
* http/https
* Web support is opt-in (turned off by default).
* Links can be opened in a user's default web browser when in a graphical environment.
* Web pages can be rendered directly in Bombadillo if [Lynx](https://lynx.invisible-island.net/), [w3m](http://w3m.sourceforge.net/), or [elinks](http://elinks.or.cz/) are installed on the system to handle the document parsing.
## Getting Started
These instructions will get a copy of the project up and running on your local machine.
These instructions will get a copy of the project up and running on your local machine. The following only applies if you are building from source (rather than using a precompiled binary).
### Prerequisites
If building from source, you will need to have [Go](https://golang.org/) version >= 1.11. Bombadillo uses the module system, so if using 1.11 you will need to have that feature enabled. If using a version > 1.11, you already have modules enabled.
You will need to have [Go](https://golang.org/) version >= 1.11.
Bombadillo does not use any outside dependencies beyond the Go standard library.
### Building, Installing, Uninstalling
### Installing
Bombadillo installation uses `make`. It is also possible to use Go to build and install (i.e `go build`, `go install`), but this is not the recommended approach.
Assuming you have `go` installed, run the following:
Running `make` from the source code directory will build Bombadillo in the local directory. This is fine for testing or trying things out. For usage system-wide, and easy access to documentation, follow the installation instructions below.
```
#### Basic Installation
Most users will want to install using the following commands:
```shell
git clone https://tildegit.org/sloum/bombadillo.git
cd bombadillo
go install
sudo make install
```
*Note: the usage of `sudo` here will be system dependent. Most systems will require it for installation to `/usr/local/bin`.*
Once you have done that you should, assuming `go install` is set up to install to a place on your path, you should be able to run the following from anywhere on your system to use Bombadillo:
```
You can then start Bombadillo by running the command:
```shell
bombadillo
```
To familiarize yourself with the application, documentation is available by running the command:
```shell
man bombadillo
```
#### Custom Installation
##### Configuration Options
There are a number of default configuration options in the file `defaults.go`, allowing customisation of the default settings for users of Bombadillo.
To use this feature, amend the `defaults.go` file as appropriate, then follow the standard install instructions.
Full documentation for these options is contained within the `defaults.go` file.
An administrator might use this to feature to set a default for all users of a system. Typically though, these options should not need changing, and a user may change most of these settings themselves once they start Bombadillo. The one option that can only be configured in `defaults.go` is `configlocation` which controls where `.bombadillo.ini` is stored.
##### Override Install Location
The installation location can be overridden at compile time, which can be very useful for administrators wanting to set up Bombadillo on a multi-user machine.
```shell
git clone https://tildegit.org/sloum/bombadillo.git
cd bombadillo
sudo make install PREFIX=/some/directory
```
There are two things to know about when using the above format:
1. The above would install Bombadillo to `/some/directory/bin`, _not_ to `/some/directory`. So you will want to make sure your `$PATH` is set accordingly.
2. Using the above will install the man page to `/some/directory/share/man/man1`, rather than its usual location. You will want to update your `manpath` accordingly.
There are other overrides available - please review the [Makefile](Makefile) for more information.
#### Uninstall
If you used the Makefile to install Bombadillo then uninstalling is very simple. From the Bombadillo source folder run:
```shell
sudo make uninstall
```
If you used a custom `PREFIX` value during install, you will need to supply it when uninstalling:
```shell
sudo make uninstall PREFIX=/some/directory
```
Uninstall will clean up any build files, remove the installed binary, and remove the man page from the system. It will _not_ remove any directories created as a part of the installation, nor will it remove any Bombadillo user configuration files.
#### Troubleshooting
If you run `bombadillo` and get `bombadillo: command not found`, try running `go build` from within the cloned repo. Then try: `./bombadillo`. If that works it means that Go does not install to your path. `go build` added an executable file to the repo directory. Move that file to somewhere on your path. I suggest `/usr/local/bin` on most systems, but that may be a matter of personal preference.
If you run `bombadillo` and get `bombadillo: command not found`, try running `make` from within the cloned repo. Then try: `./bombadillo`. If that works it means that the application is getting built correctly and the issue is likely in your path settings. Any errors during `make install` should be visible, and you will be able to see what command it failed on.
### Downloading
If you would prefer to download a binary for your system, rather than build from source, please visit the [Bombadillo downloads](https://rawtext.club/~sloum/bombadillo.html#downloads) page. Don't see your OS/architecture? Bombadillo can be built for use with any POSIX compliant system that is supported as a target for the Go compiler (Linux, BSD, OS X, Plan 9). No testing has been done for Windows. The program will build, but will likely not work properly outside of the Linux subsystem. If you are a Windows user and would like to do some testing or get involved in development please reach out or open an issue.
If you would prefer to download a binary for your system, rather than build from source, please visit the [Bombadillo releases](http://bombadillo.colorfield.space/releases) page. Don't see your OS/architecture? Bombadillo can be built for use with any system that is supported as a target for the Go compiler (Linux, BSD, OS X, Plan 9). There is no explicit support for, or testing done for, Windows or Plan 9. The program should build on those systems, but you may encounter unexpected behaviors or incompatibilities.
### Documentation
Bombadillo has documentation available in three places currently. The first is the [Bombadillo homepage](https://rawtext.club/~sloum/bombadillo.html#docs), which has lots of information about the program, links to places around Gopher, and documentation of the commands and configuration options.
Bombadillo's primary documentation can be found in the man entry that installs with Bombadillo. To access it run `man bombadillo` after first installing Bombadillo. If for some reason that does not work, the document can be accessed directly from the source folder with `man ./bombadillo.1`.
Secondly, and possibly more importantly, documentation is available via Gopher from within Bombadillo. When a user launches Bombadillo for the first time, their `homeurl` is set to the help file. As such they will have access to all of the key bindings, commands, and configuration from the first run. A user can also type `:?` or `:help` at any time to return to the documentation. Remember that Bombadillo uses vim-like key bindings, so scroll with `j` and `k` to view the docs file.
Lastly, this repo contains a file `bombadillo-info`. This is a duplicate of the help file that is hosted over gopher mentioned above. Per user request it has been added to the repo so that pull requests can be created with updates to the documentation.
The longterm hope is to create an installer of some sort that will move bombadillo onto a users path (compiling if need be) and installing a man file (yet to be created) onto their system. There is also talk about being able to open local files and use bombadillo as a pager, which would enable linking in the included help file.
In addition to the man page, users can get information on Bombadillo on the web @ [http://bombadillo.colorfield.space](http://bombadillo.colorfield.space). Running the command `help` inside Bombadillo will navigate a user to the gopher server hosted at [bombadillo.colorfield.space](gopher://bombadillo.colorfield.space); specifically the user guide.
## Contributing
Bombadillo development is largely handled by Sloum, with help from jboverf and some community input. If you would like to get involved, please reach out or submit an issue. At present the developers use the tildegit issues system to discuss new features, track bugs, and communicate with users about hopes and/or issues for/with the software.
Bombadillo development is largely handled by Sloum, with help from asdf, jboverf, and community input.
There are many ways to contribute to Bombadillo, including a fair few that don't require knowledge of programming:
- Try out the browser and let us know if you have a suggestion for improvement, or if you find a bug.
- Read the documentation and let us know if something isn't well explained, or needs correction.
- Maybe you have a cool logo or some art that you think would look nice.
If you have something in mind, please reach out or [open an issue](https://tildegit.org/sloum/bombadillo/issues).
We aim for simplicity and quality, and do our best to make Bombadillo useful to its users. Any proposals for change are reviewed by the maintainers with this in mind, and not every request will be accepted. Furthermore, this software is developed in our spare time for the good of all, and help is provided as best efforts. In general, we want to help!
The maintainers use the [tildegit](https://tildegit.org) issues system to discuss new features, track bugs, and communicate with users regarding issues and suggestions. Pull requests should typically have an associated issue, and should target the `develop` branch.
## Development
Following the standard install instructions should lead you to have nearly everything you need to commence development. The only additions to this are:
- To be able to submit pull requests, you will need to fork this repository first.
- The build process must be tested with Go 1.11 to ensure backward compatibility. This version can be installed as per the [Go install documentation](https://golang.org/doc/install#extra_versions). Check that changes build with this version using `make test`.
- Linting must be performed on new changes using `gofmt` and [golangci-lint](https://github.com/golangci/golangci-lint)
## License
This project is licensed under the GNU GPL version 3- see the [LICENSE](LICENSE) file for details.
This project is licensed under the GNU GPL version 3. See the [LICENSE](LICENSE) file for details.
## Releases
Starting with version 2.0.0 releases into `master` will be version-tagged. Work done toward the next release will be created on work branches named for what they are doing and then merged into `develop` to be combined with other ongoing efforts before a release is merged into `master`. At present there is no specific release schedule. It will depend on the urgency of the work that makes its way into develop and will be up to the project maintainers' judgement when to release from `develop`.

1
VERSION Normal file
View File

@ -0,0 +1 @@
v2.0.0

BIN
bombadillo-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,162 +0,0 @@
i false null.host 70
i false null.host 70
i **** bombadillo **** false null.host 70
i false null.host 70
ibombadillo is a gopher client for the terminal. it functions as a pager false null.host 70
iwith a "full screen" terminal user interface. keys are mapped similarly false null.host 70
ito vim (as detailed below). source code can be downloaded from the link false null.host 70
iand is written in golang. linux and osx are fully supported for both arm false null.host 70
iand x86_64. false null.host 70
i false null.host 70
iin bombadillo, scroll down with 'j' and up with 'k'. this is being false null.host 70
ilisted here to facilitate easier viewing of the rest of this doc false null.host 70
ifor first time users. false null.host 70
i false null.host 70
i false null.host 70
isource code is available here: false null.host 70
hhttp://tildegit.org/sloum/bombadillo url:http://tildegit.org/sloum/bombadillo colorfield.space 70
i false null.host 70
iweb based documentation/links available here: false null.host 70
hhttps://rawtext.club/~sloum/bombadillo.html url:https://rawtext.club/~sloum/bombadillo.html colorfield.space 70
i false null.host 70
i false null.host 70
ito open the above link in bombadillo a user must enable the feature. to do so false null.host 70
ia user would ":set openhttp true". this will open the http based web link in false null.host 70
itheir default web browser. a user can change back to false at any time if false null.host 70
iprefer to not open non-gopher links. if a default web browser is not set, the false null.host 70
itrue value will still result in failure :( unfortunately, if you are in a false null.host 70
inon-graphical environment entiely (such as in a remote shell) you will likely false null.host 70
inot be able to open a web browser as lynx (or the like) are not generally false null.host 70
iset up to work as a system default browser.
i false null.host 70
ibombadillo uses, if it is available, the alternate terminal buffer. this will false null.host 70
ihelp keep your terminal clean when you exit as well as create a better full false null.host 70
iscreen experience in a terminal. a configuration flag to toggle this feature false null.host 70
iis in the works.
i false null.host 70
i false null.host 70
i** quick start ** false null.host 70
i false null.host 70
iupon opening bombadillo for the first time a user will be presented with this false null.host 70
iscreen and a top bar with the application title. to visit a page a user can false null.host 70
ienter a colon followed by a gopher url or a link number (shown on the active false null.host 70
ipage to the left of link text and to the right of the gopher type). For false null.host 70
iexample: false null.host 70
i false null.host 70
i:colorfield.space false null.host 70
i false null.host 70
iupon doing so the user will see the colorfield.space gopher page false null.host 70
iyou will see the ':' key come up a lot as it leads into many commands. false null.host 70
i false null.host 70
iyou can pass a url to bombadillo when opening it from the terminal. false null.host 70
idoing so will open the client directly to that url. for example: false null.host 70
i false null.host 70
i bombadillo gopher://colorfield.space false null.host 70
i false null.host 70
i false null.host 70
iA note on window resizing: false null.host 70
i false null.host 70
iIf you resize your terminal window the screen will wrap text in weird/wild false null.host 70
iways. Pressing any key when the screen is in this state will redraw the false null.host 70
iscreen and realign the text. false null.host 70
i false null.host 70
i false null.host 70
i false null.host 70
i** hot keys ** false null.host 70
i false null.host 70
isome keys function as "hot keys". when pressed, they will initite an false null.host 70
iaction immediately. the following keys work as hot keys: false null.host 70
i false null.host 70
ikey behavior false null.host 70
i-------- -------------------------------------------------------------- false null.host 70
i q quit bombadillo false null.host 70
i b back (go back a place in browsing history if available) false null.host 70
i f forward (go forward a place in browsing history if available) false null.host 70
i j scroll down (if there is room to do so) false null.host 70
i k scroll up (if there is room to do so) false null.host 70
i G scroll to bottom (end) false null.host 70
i g scroll to top (home) false null.host 70
i d page down false null.host 70
i u page up false null.host 70
i B toggle bookmarks sidebar into or out of view false null.host 70
i : enter command mode false null.host 70
i SPC enter command mode false null.host 70
i TAB cycle window focus false null.host 70
i false null.host 70
i *window focus changes only have an effect if the bookmark window is open. false null.host 70
i Changing focus will allow the focused window to be scrolled while both false null.host 70
i windows are visible on screen. false null.host 70
i false null.host 70
i false null.host 70
i false null.host 70
i** commands ** false null.host 70
i false null.host 70
ionce in command mode, the following commands are available: false null.host 70
i (most can function using just their first letter... ex: false null.host 70
i :q will quit false null.host 70
i :w . somefile.txt will save the current file as somefile.txt false null.host 70
i all current commands work this way in addition to their long form) false null.host 70
i false null.host 70
i false null.host 70
iaction syntax notes false null.host 70
i----------------- -------------------------------- --------------------- false null.host 70
iquit bombadillo :quit same as 'q' hot key false null.host 70
ivisit homepage :home set by homeurl option false null.host 70
ivisit help :help short version :? false null.host 70
isearch :search will ask for kwds false null.host 70
ivisit url :[url] valid gopher url false null.host 70
ivisit link :[number] link # on active page false null.host 70
iview bookmarks :bookmarks same as 'B' hot key false null.host 70
ivisit bookmark :bookmarks [number] valid bookmark # false null.host 70
iadd bookmark :add [url] [bookmark title] valid gopher url false null.host 70
iadd bookmark :add [link #] [bookmark title] link # on active page false null.host 70
iadd bookmark :add . [bookmark title] adds active page false null.host 70
idelete bookmark :delete [number] valid bookmark # false null.host 70
iset an option :set [option] [value] used for configuration false null.host 70
icheck an option :check [option name] used to check config false null.host 70
iwrite to file :write [url] [file name] valid gopher url false null.host 70
iwrite to file :write [link #] [file name] link # on active page false null.host 70
iwrite to file :write . [file name] saves active page false null.host 70
i false null.host 70
i *: navigating to a non-text gophertype will automaticall save false null.host 70
i files save to the path set by the 'saveurl' option (defaults false null.host 70
i to a user's Downloads folder in their home directory). false null.host 70
i **: search is entered on its own and will query the user for keywords false null.host 70
i and will then query the search service set as 'searchengine' false null.host 70
i (defaults to veronica2) false null.host 70
i false null.host 70
i false null.host 70
i false null.host 70
i false null.host 70
i** configuartion ** false null.host 70
i false null.host 70
ivarious configuartion options can be set via the 'set' command. false null.host 70
ithe following are the currently avialable options, more will be false null.host 70
icoming in the near future (including color theme options) false null.host 70
i false null.host 70
ionce a user sets an option or adds a bookmark a config file will be false null.host 70
icreated in their home directory. the file will be named '.bombadillo.ini' false null.host 70
iwhile it can be edited directly, it is recommended to use bombadillo false null.host 70
ito interact with said file.
i false null.host 70
i false null.host 70
i option value type default false null.host 70
i--------------- ------------------------- ------------------------ false null.host 70
i false null.host 70
ihomeurl valid gopher url this document false null.host 70
isavelocation an non-relative filepath /[home path]/Downloads/ false null.host 70
isearchengine a type 7 gopher url gopher.floodgap.com:70/7/v2/vs false null.host 70
iopenhttp true/false false false null.host 70
i false null.host 70
i false null.host 70
i false null.host 70
i false null.host 70
i false null.host 70
i false null.host 70
ifor more information please contact the following: false null.host 70
1colorfield space / colorfield.space 70
isloum@sdf.org false null.host 70
htildegit url:http://tildergit.org/sloum colorfield.space 70
i false null.host 70
i false null.host 70
i false null.host 70

BIN
bombadillo-screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

261
bombadillo.1 Normal file
View File

@ -0,0 +1,261 @@
.TH "bombadillo" 1 "27 OCT 2019" "" "General Operation Manual"
.SH NAME
\fBbombadillo \fP- a non-web browser
.SH SYNOPSIS
.nf
.fam C
\fBbombadillo\fP [\fIurl\fP]
\fBbombadillo\fP [\fBOPTION\fP]
.fam T
.fi
.SH DESCRIPTION
\fBbombadillo\fP is a non-web browser for the terminal. It features a full terminal user interface, vim-like keybindings, document pager, configurable settings, and a robust command selection.
.TP
\fBbombadillo\fP supports the following protocols as first class citizens: gopher, gemini, finger, and local (a users file system). Support for telnet, http and https is also available via integration with third party applications.
.SH OPTIONS
.TP
.B
\fB-v\fP
Display version information and exit.
.TP
.B
\fB-h\fP
Usage help. Displays all command line options with a short description.
.SH PROTOCOL SUPPORT
All of the below protocols are supported. With the exception of gopher, the protocol name must be present as the scheme component of a url in the form of \fI[protocol]://[the rest of the url]\fP.
.TP
.B
gopher
Gopher is the default protocol for \fBbombadillo\fP. Any textual item types will be visited and shown to the user and any non-text types will be downloaded. Type 7 (querying) is fully supported. As the default protocol, any url that is not prefixed with the scheme section of a url (\fIgopher://\fP for example) will be treated as gopher urls.
.TP
.B
gemini
Gemini is supported, but as a new protocol with an incomplete specification, features may change over time. At present Bombadillo supports TLS with a trust on first use certificate pinning system (similar to SSH). Client certificates are also supported as a configurable option. Gemini maps and other text types are rendered in the browser and non-text types will be downloaded.
.TP
.B
finger
Basic support is provided for the finger protocol. The format is: \fIfinger://[[username@]][hostname]\fP. Many servers still support finger and it can be fun to see if friends are online or read about the users whose phlogs you follow.
.TP
.B
local
Local is similar to the \fIfile\fP protocol used in web browsers or the like, with a smaller set of features. Users can use the local scheme to view files on their local system. Directories are supported as viewable text object as well as any files. Wildcards and globbing are not supported. Using \fI~\fP to represent a user's home directory, as well as relative paths, are supported.
.TP
.B
telnet
Telnet is not supported directly, but addresses will be followed and opened as a subprocess by whatever telnet client a user sets in their settings (defaulting to \fItelnet\fP). In some cases this behavior may be buggy.
.TP
.B
http, https
Neither of the world wide web protocols are supported directly. \fBbombadillo\fP can be configured to open web links in a user's default graphical web browser. It is also possible to display web content directly in \fBbombadillo\fP using lynx, w3m, or elinks terminal web browsers to render pages. Opening http/https links is opt-in only, controlled by the \fIwebmode\fP setting.
.IP
Opening links in a default graphical web browser will only work in a GUI environment.
.IP
Displaying web content directly in \fBbombadillo\fP requires lynx, w3m or elinks terminal web browsers are installed on the system.
.SH COMMANDS
.SS KEY COMMANDS
These commands work as a single keypress anytime \fBbombadillo\fP is not taking in a line based command or when the user is being prompted for action. This is the default command mode of \fBbombadillo\fP.
.TP
.B
b
Navigate back one place in your document history.
.TP
.B
B
Toggle the bookmarks panel open/closed.
.TP
.B
d
Scroll down an amount corresponding to 75% of your terminal window height in the current document.
.TP
.B
f
Navigate forward one place in your document history.
.TP
.B
g
Scroll to the top of the current document.
.TP
.B
G
Scroll to the bottom of the current document.
.TP
.B
j
Scroll down a single line in the current document.
.TP
.B
k
Scroll up a single line.
.TP
.B
q
Quit \fBbombadillo\fP.
.TP
.B
R
Reload the current page (does not destroy forward history).
.TP
.B
u
Scroll up an amount corresponding to 75% of your terminal window height in the current document.
.TP
.B
<tab>
Toggle the scroll focus between the bookmarks panel and the document panel. Only has an effect if the bookmarks panel is open.
.TP
.B
<spc>
Enter line command mode. Once a line command is input, the mode will automatically revert to key command mode.
.TP
.B
:
Alias for <spc>. Enter line command mode.
.SS LINE COMMANDS
These commands are typed in by the user to perform an action of some sort. As listed in KEY COMMANDS, this mode is initiated by pressing : or <space>. The command names themselves are not case sensitive, though the arguments supplied to them may be.
.TP
.B
[url]
Navigates to the requested url.
.TP
.B
[link id]
Follows a link on the current document with the given number.
.TP
.B
add [url] [name\.\.\.]
Adds the url as a bookmarks labeled by name. \fIa\fP can be used instead of the full \fIadd\fP.
.TP
.B
add [link id] [name\.\.\.]
Adds the url represented by the link id within the current document as a bookmark labeled by name. \fIa\fP can be used instead of the full \fIadd\fP.
.TP
.B
add . [name\.\.\.]
Adds the current document's url as a bookmark labeled by name. \fIa\fP can be used instead of the full \fIadd\fP.
.TP
.B
bookmarks
Toggles the bookmarks panel open/closed. Alias for KEY COMMAND \fIB\fP. \fIb\fP can be used instead of the full \fIbookmarks\fP.
.TP
.B
bookmarks [bookmark id]
Navigates to the url represented by the bookmark matching bookmark id. \fIb\fP can be entered, rather than the full \fIbookmarks\fP.
.TP
.B
check [link id]
Displays the url corresponding to a given link id for the current document. \fIc\fP can be used instead of the full \fIcheck\fP.
.TP
.B
check [setting name]
Displays the current value for a given configuration setting. \fIc\fP can be used instead of the full \fIcheck\fP.
.TP
.B
delete [bookmark id]]
Deletes the bookmark matching the bookmark id. \fId\fP can be used instead of the full \fIdelete\fP.
.TP
.B
help
Navigates to the gopher based help page for \fBbombadillo\fP. \fI?\fP can be used instead of the full \fIhelp\fP.
.TP
.B
home
Navigates to the document set by the \fIhomeurl\fP setting. \fIh\fP can be entered, rather than the full \fIhome\fP.
.TP
.B
purge *
Deletes all pinned gemini server certificates. \fIp\fP can be used instead of the full \fIpurge\fP.
.TP
.B
purge [host name]
Deletes the pinned gemini server certificate for the given hostname. \fIp\fP can be used instead of the full \fIpurge\fP.
.TP
.B
quit
Quits \fBbombadillo\fP. Alias for KEY COMMAND \fIq\fP. \fIq\fP can be used instead of the full \fIquit\fP.
.TP
.B
reload
Requests the current document from the server again. This does not break forward history the way entering the url again would. \fIr\fP can be used instead of the full \fIreload\fP.
.TP
.B
search
Queries the user for search terms and submits a search to the search engine set by the \fIsearchengine\fP setting.
.TP
.B
search [keywords\.\.\.]
Submits a search to the search engine set by the \fIsearchengine\fP setting, with the query being the provided keyword(s).
.TP
.B
set [setting name]
Sets the value for a given configuration setting. \fIs\fP can be used instead of the full \fIset\fP.
.TP
.B
write .
Writes the current document to a file. The file is named by the last component of the url path. If the last component is blank or \fI/\fP a default name will be used. The file saves to the directory set by the \fIsavelocation\fP setting. \fIw\fP can be entered rather than the full \fIwrite\fP.
.TP
.B
write [url]
Writes data from a given url to a file. The file is named by the last component of the url path. If the last component is blank or \fI/\fP a default name will be used. The file saves to the directory set by the \fIsavelocation\fP setting. \fIw\fP can be entered rather than the full \fIwrite\fP.
.TP
.B
write [link id]
Writes data from a given link id in the current document to a file. The file is named by the last component of the url path. If the last component is blank or \fI/\fP a default name will be used. The file saves to the directory set by the \fIsavelocation\fP setting. \fIw\fP can be entered rather than the full \fIwrite\fP.
.SH FILES
\fBbombadillo\fP keeps a hidden configuration file in a user's XDG configuration directory. The file is a simplified ini file titled \fI.bombadillo.ini\fP. It is generated when a user first loads \fBbombadillo\fP and is updated with bookmarks and settings as a user adds them. The file can be directly edited, but it is best to use the SET command to update settings whenever possible. To return to the state of a fresh install, simply remove the file and a new one will be generated with the \fBbombadillo\fP defaults. On some systems an administrator may set the configuration file location to somewhere other than the default setting. If you do not see the file where you expect it, or if your settings are not being read, try \fI:check configlocation\fP to see where the file should be, or contact your system administrator for more information.
.SH SETTINGS
The following is a list of the settings that \fBbombadillo\fP recognizes, as well as a description of their valid values.
.TP
.B
configlocation
The path to the directory that the \fI.bombadillo.ini\fP configuration file is stored in. This is a \fBread only\fP setting and cannot be changed with the \fIset\fP command, but it can be read with the \fIcheck\fP command.
.TP
.B
homeurl
The url that \fBbombadillo\fP navigates to when the program loads or when the \fIhome\fP or \fIh\fP LINE COMMAND is issued. This should be a valid url. If a scheme/protocol is not included, gopher will be assumed.
.TP
.B
savelocation
The path to the directory that \fBbombadillo\fP should write files to. This must be a valid filepath for the system, must be a directory, and must already exist.
.TP
.B
searchengine
The url to use for the LINE COMMANDs \fI?\fP and \fIsearch\fP. Should be a valid search path that terms may be appended to.
.TP
.B
telnetcommand
Tells the browser what command to use to start a telnet session. Should be a valid command, including any flags. The address being navigated to will be added to the end of the command.
.TP
.B
theme
Can toggle between visual modes. Valid values are \fInormal\fP and \fIinverse\fP. When set to inverse, the terminal color mode is inverted.
.TP
.B
tlscertificate
A path to a tls certificate file on a user's local filesystem. Defaults to NULL. Both \fItlscertificate\fP and \fItlskey\fP must be set for client certificates to work in gemini.
.TP
.B
tlskey
A path to a tls key that pairs with the tlscertificate setting, on a user's local filesystem. Defaults to NULL. Both \fItlskey\fP and \fItlscertificate\fP must be set for client certificates to work in gemini.
.TP
.B
webmode
Controls behavior when following web links. The following values are valid: \fInone\fP will disable following web links, \fIgui\fP will have the browser attempt to open web links in a user's default graphical web browser; \fIlynx\fP, \fIw3m\fP, and \fIelinks\fP will have the browser attempt to use the selected terminal web browser to handle the rendering of web pages and will display the pages directly in Bombadillo.
.SH BUGS
There are very likely bugs. Many known bugs can be found in the issues section of \fBbombadillo\fP's source code repository (see \fIlinks\fP).
.SH LINKS
\fBbombadillo\fP maintains a presence in the following locations:
.TP
.B
Source Code Repository
https://tildegit.org/sloum/bombadillo
.TP
.B
Web Homepage
http://bombadillo.colorfield.space
.TP
.B
Gopher Homepage
gopher://bombadillo.colorfield.space
.SH AUTHORS
\fBbombadillo\fP was primarily developed by sloum, with kind and patient assistance from ~asdf and jboverf.

10
bombadillo.desktop Normal file
View File

@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=Bombadillo
GenericName=Non-Web Browser
Comment=View gopher, gemini, finger, telnet, http(s) sites over the internet
Terminal=true
Categories=Network;WebBrowser;ConsoleOnly;
Exec=bombadillo %U
Icon=bombadillo-icon
MimeType=x-scheme-handler/gopher;x-scheme-handler/gemini;x-scheme-handler/finger;

149
bookmarks.go Normal file
View File

@ -0,0 +1,149 @@
package main
import (
"fmt"
"strings"
"tildegit.org/sloum/bombadillo/cui"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
// Bookmarks represents the contents of the bookmarks
// bar, as well as its visibility, focus, and scroll
// state.
type Bookmarks struct {
IsOpen bool
IsFocused bool
Position int
Length int
Titles []string
Links []string
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
// Add a bookmark to the bookmarks struct
func (b *Bookmarks) Add(v []string) (string, error) {
if len(v) < 2 {
return "", fmt.Errorf("Received %d arguments, expected 2+", len(v))
}
b.Titles = append(b.Titles, strings.Join(v[1:], " "))
b.Links = append(b.Links, v[0])
b.Length = len(b.Titles)
return "Bookmark added successfully", nil
}
// Delete a bookmark from the bookmarks struct
func (b *Bookmarks) Delete(i int) (string, error) {
if i < len(b.Titles) && len(b.Titles) == len(b.Links) {
b.Titles = append(b.Titles[:i], b.Titles[i+1:]...)
b.Links = append(b.Links[:i], b.Links[i+1:]...)
b.Length = len(b.Titles)
return "Bookmark deleted successfully", nil
}
return "", fmt.Errorf("Bookmark %d does not exist", i)
}
// ToggleOpen toggles visibility state of the bookmarks bar
func (b *Bookmarks) ToggleOpen() {
b.IsOpen = !b.IsOpen
if b.IsOpen {
b.IsFocused = true
} else {
b.IsFocused = false
}
}
// ToggleFocused toggles the focal state of the bookmarks bar
func (b *Bookmarks) ToggleFocused() {
if b.IsOpen {
b.IsFocused = !b.IsFocused
}
}
// IniDump returns a string representing the current bookmarks
// in the format that .bombadillo.ini uses
func (b Bookmarks) IniDump() string {
if len(b.Titles) < 1 {
return ""
}
out := "[BOOKMARKS]\n"
for i := 0; i < len(b.Titles); i++ {
out += b.Titles[i]
out += "="
out += b.Links[i]
out += "\n"
}
return out
}
// List returns a list, including link nums, of bookmarks
// as a string slice
func (b Bookmarks) List() []string {
var out []string
for i, t := range b.Titles {
out = append(out, fmt.Sprintf("[%d] %s", i, t))
}
return out
}
// Render returns a string slice with the contents of each
// visual row of the bookmark bar.
func (b Bookmarks) Render(termwidth, termheight int) []string {
width := 40
termheight -= 3
var walll, wallr, floor, ceil, tr, tl, br, bl string
if termwidth < 40 {
width = termwidth
}
if b.IsFocused {
walll = cui.Shapes["awalll"]
wallr = cui.Shapes["awallr"]
ceil = cui.Shapes["aceiling"]
floor = cui.Shapes["afloor"]
tr = cui.Shapes["atr"]
br = cui.Shapes["abr"]
tl = cui.Shapes["atl"]
bl = cui.Shapes["abl"]
} else {
walll = cui.Shapes["walll"]
wallr = cui.Shapes["wallr"]
ceil = cui.Shapes["ceiling"]
floor = cui.Shapes["floor"]
tr = cui.Shapes["tr"]
br = cui.Shapes["br"]
tl = cui.Shapes["tl"]
bl = cui.Shapes["bl"]
}
out := make([]string, 0, 5)
contentWidth := width - 2
top := fmt.Sprintf("%s%s%s", tl, strings.Repeat(ceil, contentWidth), tr)
out = append(out, top)
marks := b.List()
for i := 0; i < termheight-2; i++ {
if i+b.Position >= len(b.Titles) {
out = append(out, fmt.Sprintf("%s%-*.*s%s", walll, contentWidth, contentWidth, "", wallr))
} else {
out = append(out, fmt.Sprintf("%s%-*.*s%s", walll, contentWidth, contentWidth, marks[i+b.Position], wallr))
}
}
bottom := fmt.Sprintf("%s%s%s", bl, strings.Repeat(floor, contentWidth), br)
out = append(out, bottom)
return out
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
// MakeBookmarks creates a Bookmark struct with default values
func MakeBookmarks() Bookmarks {
return Bookmarks{false, false, 0, 0, make([]string, 0), make([]string, 0)}
}

1058
client.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -68,9 +68,11 @@ func (s *scanner) scanText() Token {
capInput := strings.ToUpper(buf.String())
switch capInput {
case "DELETE", "ADD", "WRITE", "SET", "RECALL", "R", "SEARCH",
"W", "A", "D", "S", "Q", "QUIT", "B", "BOOKMARKS", "H",
"HOME", "?", "HELP", "C", "CHECK":
case "D", "DELETE", "A", "ADD", "W", "WRITE",
"S", "SET", "R", "RELOAD", "SEARCH",
"Q", "QUIT", "B", "BOOKMARKS", "H",
"HOME", "?", "HELP", "C", "CHECK",
"P", "PURGE":
return Token{Action, capInput}
}

View File

@ -4,7 +4,6 @@ import (
"fmt"
"io"
"strings"
"tildegit.org/sloum/bombadillo/gopher"
)
//------------------------------------------------\\
@ -21,9 +20,12 @@ type Parser struct {
}
type Config struct {
Bookmarks gopher.Bookmarks
Colors []KeyValue
Settings []KeyValue
// Bookmarks gopher.Bookmarks
Bookmarks struct {
Titles, Links []string
}
Settings []KeyValue
Certs []KeyValue
}
type KeyValue struct {
@ -86,12 +88,10 @@ func (p *Parser) Parse() (Config, error) {
}
switch section {
case "BOOKMARKS":
err := c.Bookmarks.Add([]string{keyval.Value, keyval.Key})
if err != nil {
return c, err
}
case "COLORS":
c.Colors = append(c.Colors, keyval)
c.Bookmarks.Titles = append(c.Bookmarks.Titles, keyval.Value)
c.Bookmarks.Links = append(c.Bookmarks.Links, keyval.Key)
case "CERTS":
c.Certs = append(c.Certs, keyval)
case "SETTINGS":
c.Settings = append(c.Settings, keyval)
}

View File

@ -2,39 +2,28 @@ package cui
import (
"bufio"
"bytes"
"fmt"
"os"
"os/exec"
"strings"
)
var shapes = map[string]string{
"wall": "╵",
"ceiling": "╴",
"tl": "┌",
"tr": "┐",
"bl": "└",
"br": "┘",
"awall": "║",
"aceiling": "═",
"atl": "╔",
"atr": "╗",
"abl": "╚",
"abr": "╝",
}
func drawShape(shape string) {
if val, ok := shapes[shape]; ok {
fmt.Printf("%s", val)
} else {
fmt.Print("x")
}
}
func moveThenDrawShape(r, c int, s string) {
MoveCursorTo(r, c)
drawShape(s)
var Shapes = map[string]string{
"walll": "╎",
"wallr": " ",
"ceiling": " ",
"floor": " ",
"tl": "╎",
"tr": " ",
"bl": "╎",
"br": " ",
"awalll": "▌",
"awallr": "▐",
"aceiling": "▀",
"afloor": "▄",
"atl": "▞",
"atr": "▜",
"abl": "▚",
"abr": "▟",
}
func MoveCursorTo(row, col int) {
@ -54,15 +43,29 @@ func moveCursorToward(dir string, amount int) {
}
}
// Exit performs cleanup operations before exiting the application
func Exit() {
CleanupTerm()
os.Exit(0)
}
// InitTerm sets the terminal modes appropriate for Bombadillo
func InitTerm() {
SetCharMode()
Tput("rmam") // turn off line wrapping
Tput("smcup") // use alternate screen
}
// CleanupTerm reverts changs to terminal mode made by InitTerm
func CleanupTerm() {
moveCursorToward("down", 500)
moveCursorToward("right", 500)
SetLineMode()
fmt.Print("\n")
fmt.Print("\033[?25h")
HandleAlternateScreen("rmcup")
os.Exit(0)
fmt.Print("\033[?25h") // reenables cursor blinking
Tput("smam") // turn on line wrap
Tput("rmcup") // stop using alternate screen
}
func Clear(dir string) {
@ -81,45 +84,6 @@ func Clear(dir string) {
}
// Takes the document content (as a slice of strings) and wraps any lines that
// are longer than the specified console width. returns the amended document
// content as a slice of strings.
// Word wrapping uses a "greedy" algorithm, where long lines are split in to
// words, and then rebuilt word by word to fill the available space. any
// leftover words overflow to the next line.
// To offer some support for unicode, some lengths are calculated using a slice
// of runes in the following way: len([]rune(string))
func wrapLines(s []string, consolewidth int) []string {
out := []string{}
for _, ln := range s {
if len([]rune(ln)) <= consolewidth {
out = append(out, ln)
} else {
words := strings.SplitAfter(ln, " ")
var subout bytes.Buffer
for i, wd := range words {
sublen := subout.Len()
wdlen := len([]rune(wd))
if sublen+wdlen <= consolewidth {
subout.WriteString(wd)
if i == len(words)-1 {
out = append(out, subout.String())
}
} else {
out = append(out, subout.String())
subout.Reset()
subout.WriteString(wd)
if i == len(words)-1 {
out = append(out, subout.String())
subout.Reset()
}
}
}
}
}
return out
}
func Getch() rune {
reader := bufio.NewReader(os.Stdin)
char, _, err := reader.ReadRune()
@ -165,7 +129,7 @@ func SetLineMode() {
}
}
func HandleAlternateScreen(opt string) {
func Tput(opt string) {
cmd := exec.Command("tput", opt)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout

View File

@ -1,52 +0,0 @@
package cui
import (
"reflect"
"testing"
)
// tests related to issue 31
func Test_wrapLines_space_preservation(t *testing.T) {
tables := []struct {
testinput []string
expectedoutput []string
linelength int
}{
{
//normal sentence - 20 characters - should not wrap
[]string{"it is her fav thingy"},
[]string{"it is her fav thingy"},
20,
},
{
//normal sentence - more than 20 characters - should wrap with a space at the end of the first line
[]string{"it is her favourite thing in the world"},
[]string{
"it is her favourite ",
"thing in the world",
},
20,
},
}
for _, table := range tables {
output := wrapLines(table.testinput, table.linelength)
if !reflect.DeepEqual(output, table.expectedoutput) {
t.Errorf("Expected %v, got %v", table.expectedoutput, output)
}
}
}
func Benchmark_wrapLines(b *testing.B) {
teststring := []string{
"0123456789",
"a really long line that will prolly be wrapped",
"a l i n e w i t h a l o t o f w o r d s",
"onehugelongwordaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
wrapLines(teststring, 20)
}
}

View File

@ -1,32 +0,0 @@
package cui
// MsgBar is a struct to represent a single row horizontal
// bar on the screen.
type MsgBar struct {
row int
title string
message string
showTitle bool
}
// SetTitle sets the title for the MsgBar in question
func (m *MsgBar) SetTitle(s string) {
m.title = s
}
// SetMessage sets the message for the MsgBar in question
func (m *MsgBar) SetMessage(s string) {
m.message = s
}
// ClearAll clears all text from the message bar (title and message)
func (m MsgBar) ClearAll() {
MoveCursorTo(m.row, 1)
Clear("line")
}
// ClearMessage clears all message text while leaving the title in place
func (m *MsgBar) ClearMessage() {
MoveCursorTo(m.row, len(m.title)+1)
Clear("right")
}

View File

@ -1,143 +0,0 @@
package cui
import (
"bytes"
"fmt"
"os"
"os/exec"
"strings"
)
// screenInit records whether or not the screen has been initialized
// this is used to prevent more than one screen from being used
var screenInit bool = false
// Screen represent the top level abstraction for a cui application.
// It takes up the full width and height of the terminal window and
// holds the various Windows and MsgBars for the application as well
// as a record of which window is active for control purposes.
type Screen struct {
Height int
Width int
Windows []*Window
Activewindow int
Bars []*MsgBar
}
// AddWindow adds a new window to the Screen struct in question
func (s *Screen) AddWindow(r1, c1, r2, c2 int, scroll, border, show bool) {
w := Window{box{r1, c1, r2, c2}, scroll, 0, []string{}, border, false, show, 1}
s.Windows = append(s.Windows, &w)
}
// AddMsgBar adds a new MsgBar to the Screen struct in question
func (s *Screen) AddMsgBar(row int, title, msg string, showTitle bool) {
b := MsgBar{row, title, msg, showTitle}
s.Bars = append(s.Bars, &b)
}
// DrawAllWindows loops over every window in the Screen struct and
// draws it to screen in index order (smallest to largest)
func (s Screen) DrawAllWindows() {
for _, w := range s.Windows {
if w.Show {
w.DrawWindow()
}
}
MoveCursorTo(s.Height-1, 1)
}
// Clear removes all content from the interior of the screen
func (s Screen) Clear() {
for i := 0; i <= s.Height; i++ {
MoveCursorTo(i, 0)
Clear("line")
}
}
// Clears message/error/command area
func (s *Screen) ClearCommandArea() {
MoveCursorTo(s.Height-1, 1)
Clear("line")
MoveCursorTo(s.Height, 1)
Clear("line")
MoveCursorTo(s.Height-1, 1)
}
// ReflashScreen checks for a screen resize and resizes windows if
// needed then redraws the screen. It takes a bool to decide whether
// to redraw the full screen or just the content. On a resize
// event, the full screen will always be redrawn.
func (s *Screen) ReflashScreen(clearScreen bool) {
s.DrawAllWindows()
if clearScreen {
s.DrawMsgBars()
s.ClearCommandArea()
}
}
// DrawMsgBars draws all MsgBars present in the Screen struct.
// All MsgBars are looped over and drawn in index order (sm - lg).
func (s *Screen) DrawMsgBars() {
for _, bar := range s.Bars {
fmt.Print("\033[7m")
var buf bytes.Buffer
title := bar.title
if len(bar.title) > s.Width {
title = string(bar.title[:s.Width-3]) + "..."
}
_, _ = buf.WriteString(title)
msg := bar.message
if len(bar.message) > s.Width-len(title) {
msg = string(bar.message[:s.Width-len(title)-3]) + "..."
}
_, _ = buf.WriteString(msg)
MoveCursorTo(bar.row, 1)
fmt.Print(strings.Repeat(" ", s.Width))
fmt.Print("\033[0m")
MoveCursorTo(bar.row, 1)
fmt.Print("\033[7m")
fmt.Print(buf.String())
MoveCursorTo(bar.row, s.Width)
fmt.Print("\033[0m")
}
}
// GetSize retrieves the terminal size and sets the Screen
// width and height to that size
func (s *Screen) GetSize() {
cmd := exec.Command("stty", "size")
cmd.Stdin = os.Stdin
out, err := cmd.Output()
if err != nil {
fmt.Println("Fatal error: Unable to retrieve terminal size")
os.Exit(1)
}
var h, w int
fmt.Sscan(string(out), &h, &w)
s.Height = h
s.Width = w
}
// - - - - - - - - - - - - - - - - - - - - - - - - - -
// NewScreen is a constructor function that returns a pointer
// to a Screen struct
func NewScreen() *Screen {
if screenInit {
fmt.Println("Fatal error: Cannot create multiple screens")
os.Exit(1)
}
var s Screen
s.GetSize()
for i := 0; i < s.Height; i++ {
fmt.Println()
}
SetCharMode()
Clear("screen")
screenInit = true
return &s
}

View File

@ -1,187 +0,0 @@
package cui
import (
"fmt"
"strings"
)
type box struct {
Row1 int
Col1 int
Row2 int
Col2 int
}
// TODO add coloring
type Window struct {
Box box
Scrollbar bool
Scrollposition int
Content []string
drawBox bool
Active bool
Show bool
tempContentLen int
}
func (w *Window) DrawWindow() {
w.DrawContent()
if w.drawBox {
w.DrawBox()
}
}
func (w *Window) DrawBox() {
lead := ""
if w.Active {
lead = "a"
}
moveThenDrawShape(w.Box.Row1, w.Box.Col1, lead+"tl")
moveThenDrawShape(w.Box.Row1, w.Box.Col2, lead+"tr")
moveThenDrawShape(w.Box.Row2, w.Box.Col1, lead+"bl")
moveThenDrawShape(w.Box.Row2, w.Box.Col2, lead+"br")
for i := w.Box.Col1 + 1; i < w.Box.Col2; i++ {
moveThenDrawShape(w.Box.Row1, i, lead+"ceiling")
moveThenDrawShape(w.Box.Row2, i, lead+"ceiling")
}
for i := w.Box.Row1 + 1; i < w.Box.Row2; i++ {
moveThenDrawShape(i, w.Box.Col1, lead+"wall")
moveThenDrawShape(i, w.Box.Col2, lead+"wall")
}
}
func (w *Window) DrawContent() {
var maxlines, borderThickness, contenth int
var short_content bool = false
if w.drawBox {
borderThickness, contenth = -1, 1
} else {
borderThickness, contenth = 1, 0
}
height := w.Box.Row2 - w.Box.Row1 + borderThickness
width := w.Box.Col2 - w.Box.Col1 + borderThickness
content := wrapLines(w.Content, width)
w.tempContentLen = len(content)
if w.Scrollposition > w.tempContentLen-height {
w.Scrollposition = w.tempContentLen - height
if w.Scrollposition < 0 {
w.Scrollposition = 0
}
}
if len(content) < w.Scrollposition+height {
maxlines = len(content)
short_content = true
} else {
maxlines = w.Scrollposition + height
}
for i := w.Scrollposition; i < maxlines; i++ {
MoveCursorTo(w.Box.Row1+contenth+i-w.Scrollposition, w.Box.Col1+contenth)
fmt.Print(strings.Repeat(" ", width))
MoveCursorTo(w.Box.Row1+contenth+i-w.Scrollposition, w.Box.Col1+contenth)
fmt.Print(content[i])
}
if short_content {
for i := len(content); i <= height; i++ {
MoveCursorTo(w.Box.Row1+contenth+i-w.Scrollposition, w.Box.Col1+contenth)
fmt.Print(strings.Repeat(" ", width))
}
}
}
func (w *Window) ScrollDown() {
var borderThickness int
if w.drawBox {
borderThickness = -1
} else {
borderThickness = 1
}
height := w.Box.Row2 - w.Box.Row1 + borderThickness
if w.Scrollposition < w.tempContentLen-height {
w.Scrollposition++
} else {
fmt.Print("\a")
}
}
func (w *Window) ScrollUp() {
if w.Scrollposition > 0 {
w.Scrollposition--
} else {
fmt.Print("\a")
}
}
func (w *Window) PageDown() {
var borderThickness int
if w.drawBox {
borderThickness = -1
} else {
borderThickness = 1
}
height := w.Box.Row2 - w.Box.Row1 + borderThickness
if w.Scrollposition < w.tempContentLen-height {
w.Scrollposition += height
if w.Scrollposition > w.tempContentLen-height {
w.Scrollposition = w.tempContentLen - height
}
} else {
fmt.Print("\a")
}
}
func (w *Window) PageUp() {
var borderThickness int
if w.drawBox {
borderThickness = -1
} else {
borderThickness = 1
}
height := w.Box.Row2 - w.Box.Row1 + borderThickness
contentLength := len(w.Content)
if w.Scrollposition > 0 && height < contentLength {
w.Scrollposition -= height
if w.Scrollposition < 0 {
w.Scrollposition = 0
}
} else {
fmt.Print("\a")
}
}
func (w *Window) ScrollHome() {
if w.Scrollposition > 0 {
w.Scrollposition = 0
} else {
fmt.Print("\a")
}
}
func (w *Window) ScrollEnd() {
var borderThickness int
if w.drawBox {
borderThickness = -1
} else {
borderThickness = 1
}
height := w.Box.Row2 - w.Box.Row1 + borderThickness
if w.Scrollposition < w.tempContentLen-height {
w.Scrollposition = w.tempContentLen - height
} else {
fmt.Print("\a")
}
}

77
defaults.go Normal file
View File

@ -0,0 +1,77 @@
package main
import (
"os"
"os/user"
"path/filepath"
)
var defaultOptions = map[string]string{
// The configuration options below control the default settings for
// users of Bombadillo.
//
// Changes take effect when Bombadillo is built. Follow the standard
// install instructions after making a change.
//
// Most options can be changed by a user in the Bombadillo client, and
// changes made here will not overwrite an existing user's settings.
// The exception to both cases is "configlocation" which controls where
// .bombadillo.ini is stored. If you make changes to this setting,
// consider moving bombadillo.ini to the new location as well, so you
// (or your users) do not loose bookmarks or other preferences.
//
// Further explanation of each option is available in the man page.
// Basic Usage
//
// Any option can be defined as a string, like this:
// "option": "value"
//
// Options can also have values calculated on startup. There are two
// functions below that do just this: homePath() and xdgConfigPath()
// You can set any value to use these functions like this:
// "option": homePath()
// "option": xdgConfigPath()
// See the comments for these functions for more information on what
// they do.
//
// You can also use `filepath.Join()` if you want to build a file path.
// For example, specify "~/bombadillo" like so:
// "option": filepath.Join(homePath(), bombadillo)
// Moving .bombadillo.ini out of your home directory
//
// To ensure .bombadillo.ini is saved as per XDG config spec, change
// the "configlocation" as follows:
// "configlocation": xdgConfigPath()
"homeurl": "gopher://bombadillo.colorfield.space:70/1/user-guide.map",
"savelocation": homePath(),
"searchengine": "gopher://gopher.floodgap.com:70/7/v2/vs",
"telnetcommand": "telnet",
"configlocation": xdgConfigPath(),
"theme": "normal", // "normal", "inverted"
"tlscertificate": "",
"tlskey": "",
"webmode": "none", // "none", "gui", "lynx", "w3m", "elinks"
}
// homePath will return the path to your home directory as a string
// Usage:
// "configlocation": homeConfigPath()
func homePath() string {
var userinfo, _ = user.Current()
return userinfo.HomeDir
}
// xdgConfigPath returns the path to your XDG base directory for configuration
// i.e the contents of environment variable XDG_CONFIG_HOME, or ~/.config/
// Usage:
// "configlocation": xdgConfigPath()
func xdgConfigPath() string {
configPath := os.Getenv("XDG_CONFIG_HOME")
if configPath == "" {
return filepath.Join(homePath(), ".config")
}
return configPath
}

31
finger/finger.go Normal file
View File

@ -0,0 +1,31 @@
package finger
import (
"fmt"
"io/ioutil"
"net"
"time"
)
func Finger(host, port, resource string) (string, error) {
addr := fmt.Sprintf("%s:%s", host, port)
timeOut := time.Duration(3) * time.Second
conn, err := net.DialTimeout("tcp", addr, timeOut)
if err != nil {
return "", err
}
defer conn.Close()
_, err = conn.Write([]byte(resource + "\r\n"))
if err != nil {
return "", err
}
result, err := ioutil.ReadAll(conn)
if err != nil {
return "", err
}
return string(result), nil
}

58
footbar.go Normal file
View File

@ -0,0 +1,58 @@
package main
import (
"fmt"
"strconv"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
// Footbar deals with the values present in the
// client's footbar
type Footbar struct {
PercentRead string
PageType string
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
// SetPercentRead sets the percentage of the current
// document the user has read
func (f *Footbar) SetPercentRead(p int) {
if p > 100 {
p = 100
} else if p < 0 {
p = 0
}
f.PercentRead = strconv.Itoa(p) + "%"
}
// SetPageType sets the current page's type
// NOTE: This is not currently in use
func (f *Footbar) SetPageType(t string) {
f.PageType = t
}
// Render returns a string representing the visual display
// of the bookmarks bar
func (f *Footbar) Render(termWidth, position int, theme string) string {
pre := fmt.Sprintf("HST: (%2.2d) - - - %4s Read ", position+1, f.PercentRead)
out := "\033[0m%*.*s "
if theme == "inverse" {
out = "\033[7m%*.*s \033[0m"
}
return fmt.Sprintf(out, termWidth-1, termWidth-1, pre)
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
// MakeFootbar returns a footbar with default values
func MakeFootbar() Footbar {
return Footbar{"---", "N/A"}
}

402
gemini/gemini.go Normal file
View File

@ -0,0 +1,402 @@
package gemini
import (
"bytes"
"crypto/sha1"
"crypto/tls"
"fmt"
"io/ioutil"
"strconv"
"strings"
"time"
)
type Capsule struct {
MimeMaj string
MimeMin string
Status int
Content string
Links []string
}
type TofuDigest struct {
certs map[string]string
ClientCert tls.Certificate
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
func (t *TofuDigest) LoadCertificate(cert, key string) {
certificate, err := tls.LoadX509KeyPair(cert, key)
if err != nil {
t.ClientCert = tls.Certificate{}
return
}
t.ClientCert = certificate
}
func (t *TofuDigest) Purge(host string) error {
host = strings.ToLower(host)
if host == "*" {
t.certs = make(map[string]string)
return nil
} else if _, ok := t.certs[strings.ToLower(host)]; ok {
delete(t.certs, host)
return nil
}
return fmt.Errorf("Invalid host %q", host)
}
func (t *TofuDigest) Add(host, hash string) {
t.certs[strings.ToLower(host)] = hash
}
func (t *TofuDigest) Exists(host string) bool {
if _, ok := t.certs[strings.ToLower(host)]; ok {
return true
}
return false
}
func (t *TofuDigest) Find(host string) (string, error) {
if hash, ok := t.certs[strings.ToLower(host)]; ok {
return hash, nil
}
return "", fmt.Errorf("Invalid hostname, no key saved")
}
func (t *TofuDigest) Match(host string, cState *tls.ConnectionState) error {
host = strings.ToLower(host)
now := time.Now()
for _, cert := range cState.PeerCertificates {
if t.certs[host] != hashCert(cert.Raw) {
continue
}
if now.Before(cert.NotBefore) {
return fmt.Errorf("Certificate is not valid yet")
}
if now.After(cert.NotAfter) {
return fmt.Errorf("EXP")
}
if err := cert.VerifyHostname(host); err != nil {
return fmt.Errorf("Certificate error: %s", err)
}
return nil
}
return fmt.Errorf("No matching certificate was found for host %q", host)
}
func (t *TofuDigest) newCert(host string, cState *tls.ConnectionState) error {
host = strings.ToLower(host)
now := time.Now()
var reasons strings.Builder
for index, cert := range cState.PeerCertificates {
if index > 0 {
reasons.WriteString("; ")
}
if now.Before(cert.NotBefore) {
reasons.WriteString(fmt.Sprintf("Cert [%d] is not valid yet", index+1))
continue
}
if now.After(cert.NotAfter) {
reasons.WriteString(fmt.Sprintf("Cert [%d] is expired", index+1))
continue
}
if err := cert.VerifyHostname(host); err != nil {
reasons.WriteString(fmt.Sprintf("Cert [%d] hostname does not match", index+1))
continue
}
t.Add(host, hashCert(cert.Raw))
return nil
}
return fmt.Errorf(reasons.String())
}
func (t *TofuDigest) IniDump() string {
if len(t.certs) < 1 {
return ""
}
var out strings.Builder
out.WriteString("[CERTS]\n")
for k, v := range t.certs {
out.WriteString(k)
out.WriteString("=")
out.WriteString(v)
out.WriteString("\n")
}
return out.String()
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
func Retrieve(host, port, resource string, td *TofuDigest) (string, error) {
if host == "" || port == "" {
return "", fmt.Errorf("Incomplete request url")
}
addr := host + ":" + port
conf := &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true,
}
conf.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
return &td.ClientCert, nil
}
conn, err := tls.Dial("tcp", addr, conf)
if err != nil {
return "", fmt.Errorf("TLS Dial Error: %s", err.Error())
}
defer conn.Close()
connState := conn.ConnectionState()
// Begin TOFU screening...
// If no certificates are offered, bail out
if len(connState.PeerCertificates) < 1 {
return "", fmt.Errorf("Insecure, no certificates offered by server")
}
if td.Exists(host) {
// See if we have a matching cert
err := td.Match(host, &connState)
if err != nil && err.Error() != "EXP" {
// If there is no match and it isnt because of an expiration
// just return the error
return "", err
} else if err != nil {
// The cert expired, see if they are offering one that is valid...
err := td.newCert(host, &connState)
if err != nil {
// If there are no valid certs to offer, let the client know
return "", err
}
}
} else {
err = td.newCert(host, &connState)
if err != nil {
// If there are no valid certs to offer, let the client know
return "", err
}
}
send := "gemini://" + addr + "/" + resource + "\r\n"
_, err = conn.Write([]byte(send))
if err != nil {
return "", err
}
result, err := ioutil.ReadAll(conn)
if err != nil {
return "", err
}
return string(result), nil
}
func Fetch(host, port, resource string, td *TofuDigest) ([]byte, error) {
rawResp, err := Retrieve(host, port, resource, td)
if err != nil {
return make([]byte, 0), err
}
resp := strings.SplitN(rawResp, "\r\n", 2)
if len(resp) != 2 {
if err != nil {
return make([]byte, 0), fmt.Errorf("Invalid response from server")
}
}
header := strings.SplitN(resp[0], " ", 2)
if len([]rune(header[0])) != 2 {
header = strings.SplitN(resp[0], "\t", 2)
if len([]rune(header[0])) != 2 {
return make([]byte, 0), fmt.Errorf("Invalid response format from server")
}
}
// Get status code single digit form
status, err := strconv.Atoi(string(header[0][0]))
if err != nil {
return make([]byte, 0), fmt.Errorf("Invalid status response from server")
}
if status != 2 {
switch status {
case 1:
return make([]byte, 0), fmt.Errorf("[1] Queries cannot be saved.")
case 3:
return make([]byte, 0), fmt.Errorf("[3] Redirects cannot be saved.")
case 4:
return make([]byte, 0), fmt.Errorf("[4] Temporary Failure.")
case 5:
return make([]byte, 0), fmt.Errorf("[5] Permanent Failure.")
case 6:
return make([]byte, 0), fmt.Errorf("[6] Client Certificate Required")
default:
return make([]byte, 0), fmt.Errorf("Invalid response status from server")
}
}
return []byte(resp[1]), nil
}
func Visit(host, port, resource string, td *TofuDigest) (Capsule, error) {
capsule := MakeCapsule()
rawResp, err := Retrieve(host, port, resource, td)
if err != nil {
return capsule, err
}
resp := strings.SplitN(rawResp, "\r\n", 2)
if len(resp) != 2 {
if err != nil {
return capsule, fmt.Errorf("Invalid response from server")
}
}
header := strings.SplitN(resp[0], " ", 2)
if len([]rune(header[0])) != 2 {
header = strings.SplitN(resp[0], "\t", 2)
if len([]rune(header[0])) != 2 {
return capsule, fmt.Errorf("Invalid response format from server")
}
}
body := resp[1]
// Get status code single digit form
capsule.Status, err = strconv.Atoi(string(header[0][0]))
if err != nil {
return capsule, fmt.Errorf("Invalid status response from server")
}
// Parse the meta as needed
var meta string
switch capsule.Status {
case 1:
capsule.Content = header[1]
return capsule, nil
case 2:
mimeAndCharset := strings.Split(header[1], ";")
meta = mimeAndCharset[0]
minMajMime := strings.Split(meta, "/")
if len(minMajMime) < 2 {
return capsule, fmt.Errorf("Improperly formatted mimetype received from server")
}
capsule.MimeMaj = minMajMime[0]
capsule.MimeMin = minMajMime[1]
if capsule.MimeMaj == "text" && capsule.MimeMin == "gemini" {
if len(resource) > 0 && resource[0] != '/' {
resource = fmt.Sprintf("/%s", resource)
} else if resource == "" {
resource = "/"
}
currentUrl := fmt.Sprintf("gemini://%s:%s%s", host, port, resource)
rootUrl := fmt.Sprintf("gemini://%s:%s", host, port)
capsule.Content, capsule.Links = parseGemini(body, rootUrl, currentUrl)
} else {
capsule.Content = body
}
return capsule, nil
case 3:
// The client will handle informing the user of a redirect
// and then request the new url
capsule.Content = header[1]
return capsule, nil
case 4:
return capsule, fmt.Errorf("[4] Temporary Failure. %s", header[1])
case 5:
return capsule, fmt.Errorf("[5] Permanent Failure. %s", header[1])
case 6:
return capsule, fmt.Errorf("[6] Client Certificate Required")
default:
return capsule, fmt.Errorf("Invalid response status from server")
}
}
func parseGemini(b, rootUrl, currentUrl string) (string, []string) {
splitContent := strings.Split(b, "\n")
links := make([]string, 0, 10)
for i, ln := range splitContent {
splitContent[i] = strings.Trim(ln, "\r\n")
if len([]rune(ln)) > 3 && ln[:2] == "=>" {
var link, decorator string
subLn := strings.Trim(ln[2:], "\r\n\t \a")
splitPoint := strings.IndexAny(subLn, " \t")
if splitPoint < 0 || len([]rune(subLn))-1 <= splitPoint {
link = subLn
decorator = subLn
} else {
link = strings.Trim(subLn[:splitPoint], "\t\n\r \a")
decorator = strings.Trim(subLn[splitPoint:], "\t\n\r \a")
}
if strings.Index(link, "://") < 0 {
link = handleRelativeUrl(link, rootUrl, currentUrl)
}
links = append(links, link)
linknum := fmt.Sprintf("[%d]", len(links))
splitContent[i] = fmt.Sprintf("%-5s %s", linknum, decorator)
}
}
return strings.Join(splitContent, "\n"), links
}
func handleRelativeUrl(u, root, current string) string {
if len(u) < 1 {
return u
}
if u[0] == '/' {
return fmt.Sprintf("%s%s", root, u)
}
ind := strings.LastIndex(current, "/")
if ind < 10 {
return fmt.Sprintf("%s/%s", root, u)
}
current = current[:ind+1]
return fmt.Sprintf("%s%s", current, u)
}
func hashCert(cert []byte) string {
hash := sha1.Sum(cert)
hex := make([][]byte, len(hash))
for i, data := range hash {
hex[i] = []byte(fmt.Sprintf("%02X", data))
}
return fmt.Sprintf("%s", string(bytes.Join(hex, []byte(":"))))
}
func MakeCapsule() Capsule {
return Capsule{"", "", 0, "", make([]string, 0, 5)}
}
func MakeTofuDigest() TofuDigest {
return TofuDigest{make(map[string]string), tls.Certificate{}}
}

2
go.mod
View File

@ -1,3 +1,3 @@
module tildegit.org/sloum/bombadillo
go 1.10
go 1.11

View File

@ -1,65 +0,0 @@
package gopher
import (
"fmt"
"strings"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
//Bookmarks is a holder for titles and links that
//can be retrieved by index
type Bookmarks struct {
Titles []string
Links []string
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
// Add adds a new title and link combination to the bookmarks
// struct. It takes as input a string slice in which the first
// element represents the link and all following items represent
// the title of the bookmark (they will be joined with spaces).
func (b *Bookmarks) Add(v []string) error {
if len(v) < 2 {
return fmt.Errorf("Received %d arguments, expected 2 or more", len(v))
}
b.Titles = append(b.Titles, strings.Join(v[1:], " "))
b.Links = append(b.Links, v[0])
return nil
}
func (b *Bookmarks) Del(i int) error {
if i < len(b.Titles) && i < len(b.Links) {
b.Titles = append(b.Titles[:i], b.Titles[i+1:]...)
b.Links = append(b.Links[:i], b.Links[i+1:]...)
return nil
}
return fmt.Errorf("Bookmark %d does not exist", i)
}
func (b Bookmarks) List() []string {
var out []string
for i, t := range b.Titles {
out = append(out, fmt.Sprintf("[%d] %s", i, t))
}
return out
}
func (b Bookmarks) IniDump() string {
if len(b.Titles) < 0 {
return ""
}
out := "[BOOKMARKS]\n"
for i := 0; i < len(b.Titles); i++ {
out += b.Titles[i]
out += "="
out += b.Links[i]
out += "\n"
}
return out
}

View File

@ -21,17 +21,21 @@ import (
var types = map[string]string{
"0": "TXT",
"1": "MAP",
"h": "HTM",
"3": "ERR",
"4": "BIN",
"5": "DOS",
"s": "SND",
"g": "GIF",
"I": "IMG",
"9": "BIN",
"7": "FTS",
"6": "UUE",
"7": "FTS",
"8": "TEL",
"9": "BIN",
"g": "GIF",
"G": "GEM",
"h": "HTM",
"I": "IMG",
"p": "PNG",
"s": "SND",
"S": "SSH",
"T": "TEL",
}
//------------------------------------------------\\
@ -43,25 +47,22 @@ var types = map[string]string{
// available to use directly, but in most implementations
// using the "Visit" receiver of the History struct will
// be better.
func Retrieve(u Url) ([]byte, error) {
func Retrieve(host, port, resource string) ([]byte, error) {
nullRes := make([]byte, 0)
timeOut := time.Duration(5) * time.Second
if u.Host == "" || u.Port == "" {
if host == "" || port == "" {
return nullRes, errors.New("Incomplete request url")
}
addr := u.Host + ":" + u.Port
addr := host + ":" + port
conn, err := net.DialTimeout("tcp", addr, timeOut)
if err != nil {
return nullRes, err
}
send := u.Resource + "\n"
if u.Scheme == "http" || u.Scheme == "https" {
send = u.Gophertype
}
send := resource + "\n"
_, err = conn.Write([]byte(send))
if err != nil {
@ -73,43 +74,29 @@ func Retrieve(u Url) ([]byte, error) {
return nullRes, err
}
return result, err
return result, nil
}
// Visit is a high level combination of a few different
// types that makes it easy to create a Url, make a request
// to that Url, and add the response and Url to a View.
// Returns a copy of the view and an error (or nil).
func Visit(addr, openhttp string) (View, error) {
u, err := MakeUrl(addr)
// Visit handles the making of the request, parsing of maps, and returning
// the correct information to the client
func Visit(gophertype, host, port, resource string) (string, []string, error) {
resp, err := Retrieve(host, port, resource)
if err != nil {
return View{}, err
return "", []string{}, err
}
if u.Gophertype == "h" {
if res, tf := isWebLink(u.Resource); tf && strings.ToUpper(openhttp) == "TRUE" {
err := openBrowser(res)
if err != nil {
return View{}, err
}
text := string(resp)
links := []string{}
return View{}, fmt.Errorf("")
}
}
text, err := Retrieve(u)
if err != nil {
return View{}, err
if IsDownloadOnly(gophertype) {
return text, []string{}, nil
}
var pageContent []string
if u.IsBinary && u.Gophertype != "7" {
pageContent = []string{string(text)}
} else {
pageContent = strings.Split(string(text), "\n")
if gophertype == "1" {
text, links = parseMap(text)
}
return MakeView(u, pageContent), nil
return text, links, nil
}
func getType(t string) string {
@ -127,3 +114,70 @@ func isWebLink(resource string) (string, bool) {
}
return "", false
}
func parseMap(text string) (string, []string) {
splitContent := strings.Split(text, "\n")
links := make([]string, 0, 10)
for i, e := range splitContent {
e = strings.Trim(e, "\r\n")
if e == "." {
splitContent[i] = ""
continue
}
line := strings.Split(e, "\t")
var title string
if len(line[0]) > 1 {
title = line[0][1:]
} else {
title = ""
}
if len(line) > 1 && len(line[0]) > 0 && string(line[0][0]) == "i" {
splitContent[i] = " " + string(title)
} else if len(line) >= 4 {
link := buildLink(line[2], line[3], string(line[0][0]), line[1])
links = append(links, link)
linktext := fmt.Sprintf("(%s) %2d %s", getType(string(line[0][0])), len(links), title)
splitContent[i] = linktext
}
}
return strings.Join(splitContent, "\n"), links
}
// Returns false for all text formats (including html
// even though it may link out. Things like telnet
// should never make it into the retrieve call for
// this module, having been handled in the client
// based on their protocol.
func IsDownloadOnly(gophertype string) bool {
switch gophertype {
case "0", "1", "3", "7", "h":
return false
default:
return true
}
}
func buildLink(host, port, gtype, resource string) string {
switch gtype {
case "8", "T":
return fmt.Sprintf("telnet://%s:%s", host, port)
case "G":
return fmt.Sprintf("gemini://%s:%s%s", host, port, resource)
case "h":
u, tf := isWebLink(resource)
if tf {
if strings.Index(u, "://") > 0 {
return u
} else {
return fmt.Sprintf("http://%s", u)
}
}
return fmt.Sprintf("gopher://%s:%s/h%s", host, port, resource)
default:
return fmt.Sprintf("gopher://%s:%s/%s%s", host, port, gtype, resource)
}
}

View File

@ -1,112 +0,0 @@
package gopher
import (
"errors"
"fmt"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
// The history struct represents the history of the browsing
// session. It contains the current history position, the
// length of the active history space (this can be different
// from the available capacity in the Collection), and a
// collection array containing View structs representing
// each page in the current history. In general usage this
// struct should be initialized via the MakeHistory function.
type History struct {
Position int
Length int
Collection [20]View
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
// The "Add" receiver takes a view and adds it to
// the history struct that called it. "Add" returns
// nothing. "Add" will shift history down if the max
// history length would be exceeded, and will reset
// history length if something is added in the middle.
func (h *History) Add(v View) {
v.ParseMap()
if h.Position == h.Length-1 && h.Length < len(h.Collection) {
h.Collection[h.Length] = v
h.Length++
h.Position++
} else if h.Position == h.Length-1 && h.Length == 20 {
for x := 1; x < len(h.Collection); x++ {
h.Collection[x-1] = h.Collection[x]
}
h.Collection[len(h.Collection)-1] = v
} else {
h.Position += 1
h.Length = h.Position + 1
h.Collection[h.Position] = v
}
}
// The "Get" receiver is called by a history struct
// and returns a View from the current position, will
// return an error if history is empty and there is
// nothing to get.
func (h History) Get() (*View, error) {
if h.Position < 0 {
return nil, errors.New("History is empty, cannot get item from empty history.")
}
return &h.Collection[h.Position], nil
}
// The "GoBack" receiver is called by a history struct.
// When called it decrements the current position and
// displays the content for the View in that position.
// If history is at position 0, no action is taken.
func (h *History) GoBack() bool {
if h.Position > 0 {
h.Position--
return true
}
fmt.Print("\a")
return false
}
// The "GoForward" receiver is called by a history struct.
// When called it increments the current position and
// displays the content for the View in that position.
// If history is at position len - 1, no action is taken.
func (h *History) GoForward() bool {
if h.Position+1 < h.Length {
h.Position++
return true
}
fmt.Print("\a")
return false
}
// The "DisplayCurrentView" receiver is called by a history
// struct. It calls the Display receiver for th view struct
// at the current history position. "DisplayCurrentView" does
// not return anything, and does nothing if position is less
// that 0.
func (h *History) DisplayCurrentView() {
h.Collection[h.Position].Display()
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
// Constructor function for History struct.
// This is used to initialize history position
// as -1, which is needed. Returns a copy of
// initialized History struct (does NOT return
// a pointer to the struct).
func MakeHistory() History {
return History{-1, 0, [20]View{}}
}

View File

@ -1,9 +0,0 @@
// +build darwin
package gopher
import "os/exec"
func openBrowser(url string) error {
return exec.Command("open", url).Start()
}

View File

@ -1,9 +0,0 @@
// +build linux
package gopher
import "os/exec"
func openBrowser(url string) error {
return exec.Command("xdg-open", url).Start()
}

View File

@ -1,11 +0,0 @@
// +build !linux
// +build !darwin
// +build !windows
package gopher
import "fmt"
func openBrowser(url string) error {
return fmt.Errorf("Unsupported os for browser detection")
}

View File

@ -1,9 +0,0 @@
// +build windows
package gopher
import "os/exec"
func openBrowser(url string) error {
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
}

View File

@ -1,94 +0,0 @@
package gopher
import (
"errors"
"regexp"
"strings"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
// The url struct represents a URL for the rest of the system.
// It includes component parts as well as a full URL string.
type Url struct {
Scheme string
Host string
Port string
Gophertype string
Resource string
Full string
IsBinary bool
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
// MakeUrl is a Url constructor that takes in a string
// representation of a url and returns a Url struct and
// an error (or nil).
func MakeUrl(u string) (Url, error) {
var out Url
re := regexp.MustCompile(`^((?P<scheme>gopher|http|https|ftp|telnet):\/\/)?(?P<host>[\w\-\.\d]+)(?::(?P<port>\d+)?)?(?:/(?P<type>[01345679gIhisp])?)?(?P<resource>.*)?$`)
match := re.FindStringSubmatch(u)
if valid := re.MatchString(u); !valid {
return out, errors.New("Invalid URL or command character")
}
for i, name := range re.SubexpNames() {
switch name {
case "scheme":
out.Scheme = match[i]
case "host":
out.Host = match[i]
case "port":
out.Port = match[i]
case "type":
out.Gophertype = match[i]
case "resource":
out.Resource = match[i]
}
}
if out.Scheme == "" {
out.Scheme = "gopher"
}
if out.Host == "" {
return out, errors.New("no host")
}
if out.Scheme == "gopher" && out.Port == "" {
out.Port = "70"
} else if out.Scheme == "http" && out.Port == "" {
out.Port = "80"
} else if out.Scheme == "https" && out.Port == "" {
out.Port = "443"
}
if out.Gophertype == "" && (out.Resource == "" || out.Resource == "/") {
out.Gophertype = "1"
}
if out.Scheme == "gopher" && out.Gophertype == "" {
out.Gophertype = "0"
}
if out.Gophertype == "7" && strings.Contains(out.Resource, "\t") {
out.Gophertype = "1"
}
switch out.Gophertype {
case "1", "0", "h", "7":
out.IsBinary = false
default:
out.IsBinary = true
}
out.Full = out.Scheme + "://" + out.Host + ":" + out.Port + "/" + out.Gophertype + out.Resource
return out, nil
}

View File

@ -1,83 +0,0 @@
package gopher
import (
"fmt"
"strings"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
// View is a struct representing a gopher page. It contains
// the page content as a string slice, a list of link URLs
// as string slices, and the Url struct representing the page.
type View struct {
Content []string
Links []string
Address Url
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
// ParseMap is called by a view struct to parse a gophermap.
// It checks if the view is for a gophermap. If not,it does
// nothing. If so, it parses the gophermap into comment lines
// and link lines. For link lines it adds a link to the links
// slice and changes the content value to just the printable
// string plus a gophertype indicator and a link number that
// relates to the link position in the links slice. This
// receiver does not return anything.
func (v *View) ParseMap() {
if v.Address.Gophertype == "1" || v.Address.Gophertype == "7" {
for i, e := range v.Content {
e = strings.Trim(e, "\r\n")
if e == "." {
v.Content[i] = " "
continue
}
line := strings.Split(e, "\t")
var title string
if len(line[0]) > 1 {
title = line[0][1:]
} else {
title = ""
}
if len(line) > 1 && len(line[0]) > 0 && string(line[0][0]) == "i" {
v.Content[i] = " " + string(title)
} else if len(line) >= 4 {
fulllink := fmt.Sprintf("%s:%s/%s%s", line[2], line[3], string(line[0][0]), line[1])
v.Links = append(v.Links, fulllink)
linktext := fmt.Sprintf("(%s) %2d %s", getType(string(line[0][0])), len(v.Links), title)
v.Content[i] = linktext
}
}
}
}
// Display is called on a view struct to print the contents of the view.
// This receiver does not return anything.
func (v View) Display() {
fmt.Println()
for _, el := range v.Content {
fmt.Println(el)
}
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
// MakeView creates and returns a new View struct from
// a Url and a string splice of content. This is used to
// initialize a View with a Url struct, links, and content.
// It takes a Url struct and a content []string and returns
// a View (NOT a pointer to a View).
func MakeView(url Url, content []string) View {
v := View{content, make([]string, 0), url}
v.ParseMap()
return v
}

39
headbar.go Normal file
View File

@ -0,0 +1,39 @@
package main
import (
"fmt"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
// Headbar represents the contents of the top bar of
// the client and contains the client name and the
// current URL
type Headbar struct {
title string
url string
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
// Render returns a string with the contents of theHeadbar
func (h *Headbar) Render(width int, theme string) string {
maxMsgWidth := width - len([]rune(h.title)) - 2
if theme == "inverse" {
return fmt.Sprintf("\033[7m%s▟\033[27m %-*.*s\033[0m", h.title, maxMsgWidth, maxMsgWidth, h.url)
}
return fmt.Sprintf("%s▟\033[7m %-*.*s\033[0m", h.title, maxMsgWidth, maxMsgWidth, h.url)
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
// MakeHeadbar returns a Headbar with default values
func MakeHeadbar(title string) Headbar {
return Headbar{title, ""}
}

98
http/http_render.go Normal file
View File

@ -0,0 +1,98 @@
package http
import (
"fmt"
"io/ioutil"
"net/http"
"os/exec"
"strings"
)
// Page represents the contents and links or an http/https document
type Page struct {
Content string
Links []string
}
// Visit is the main entry to viewing a web document in bombadillo.
// It takes a url, a terminal width, and which web backend the user
// currently has set. Visit returns a Page and an error
func Visit(webmode, url string, width int) (Page, error) {
if width > 80 {
width = 80
}
var w string
switch webmode {
case "lynx":
w = "-width"
case "w3m":
w = "-cols"
case "elinks":
w = "-dump-width"
default:
return Page{}, fmt.Errorf("Invalid webmode setting")
}
c, err := exec.Command(webmode, "-dump", w, fmt.Sprintf("%d", width), url).Output()
if err != nil {
return Page{}, err
}
return parseLinks(string(c)), nil
}
// IsTextFile makes an http(s) head request to a given URL
// and determines if the content-type is text based. It then
// returns a bool
func IsTextFile(url string) bool {
resp, err := http.Head(url)
if err != nil {
return false
}
ctype := resp.Header.Get("content-type")
if strings.Contains(ctype, "text") || ctype == "" {
return true
}
return false
}
func parseLinks(c string) Page {
var out Page
contentUntil := strings.LastIndex(c, "References")
if contentUntil >= 1 {
out.Content = c[:contentUntil]
} else {
out.Content = c
out.Links = make([]string, 0)
return out
}
links := c[contentUntil+11:]
links = strings.TrimSpace(links)
linkSlice := strings.Split(links, "\n")
out.Links = make([]string, 0, len(linkSlice))
for _, link := range linkSlice {
ls := strings.SplitN(link, ".", 2)
if len(ls) < 2 {
continue
}
out.Links = append(out.Links, strings.TrimSpace(ls[1]))
}
return out
}
// Fetch makes an http(s) request and returns the []bytes
// for the response and an error. Fetch is used for saving
// the source file of an http(s) document
func Fetch(url string) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return []byte{}, err
}
defer resp.Body.Close()
bodyBytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
return []byte{}, err
}
return bodyBytes, nil
}

View File

@ -0,0 +1,13 @@
// +build darwin
package http
import "os/exec"
func OpenInBrowser(url string) (string, error) {
err := exec.Command("open", url).Start()
if err != nil {
return "", err
}
return "Opened in system default web browser", nil
}

View File

@ -0,0 +1,30 @@
// +build linux
package http
import (
"fmt"
"os"
"os/exec"
)
// OpenInBrowser checks for the presence of a display server
// and environment variables indicating a gui is present. If found
// then xdg-open is called on a url to open said url in the default
// gui web browser for the system
func OpenInBrowser(url string) (string, error) {
disp := os.Getenv("DISPLAY")
wayland := os.Getenv("WAYLAND_DISPLAY")
_, err := exec.LookPath("Xorg")
if disp == "" && wayland == "" && err != nil {
return "", fmt.Errorf("No gui is available, check 'webmode' setting")
}
// Use start rather than run or output in order
// to release the process and not block
err = exec.Command("xdg-open", url).Start()
if err != nil {
return "", err
}
return "Opened in system default web browser", nil
}

View File

@ -0,0 +1,11 @@
// +build !linux
// +build !darwin
// +build !windows
package http
import "fmt"
func OpenInBrowser(url string) (string, error) {
return "", fmt.Errorf("Unsupported os for 'webmode' 'gui' setting")
}

View File

@ -0,0 +1,13 @@
// +build windows
package http
import "os/exec"
func OpenInBrowser(url string) (string, error) {
err := exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
if err != nil {
return "", err
}
return "Opened in system default web browser", nil
}

90
local/local.go Normal file
View File

@ -0,0 +1,90 @@
package local
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"sort"
"strings"
)
func Open(address string) (string, []string, error) {
links := make([]string, 0, 10)
if !pathExists(address) {
return "", links, fmt.Errorf("Invalid system path: %s", address)
}
file, err := os.Open(address)
if err != nil {
return "", links, err
}
defer file.Close()
if pathIsDir(address) {
offset := 1
fileList, err := file.Readdir(0)
if err != nil {
return "", links, err
}
var out strings.Builder
out.WriteString(fmt.Sprintf("Current directory: %s\n\n", address))
// Handle 'addres/..' display
offset = 2
upFp := filepath.Join(address, "..")
upOneLevel, _ := filepath.Abs(upFp)
info, err := os.Stat(upOneLevel)
if err == nil {
out.WriteString("[1] ")
out.WriteString(fmt.Sprintf("%-12s ", info.Mode().String()))
out.WriteString("../\n")
links = append(links, upOneLevel)
}
// Sort the directory contents alphabetically
sort.Slice(fileList, func(i, j int) bool {
return fileList[i].Name() < fileList[j].Name()
})
// Handle each item in the directory
for i, obj := range fileList {
linkNum := fmt.Sprintf("[%d]", i+offset)
out.WriteString(fmt.Sprintf("%-5s ", linkNum))
out.WriteString(fmt.Sprintf("%-12s ", obj.Mode().String()))
out.WriteString(obj.Name())
if obj.IsDir() {
out.WriteString("/")
}
out.WriteString("\n")
fp := filepath.Join(address, obj.Name())
links = append(links, fp)
}
return out.String(), links, nil
}
bytes, err := ioutil.ReadAll(file)
if err != nil {
return "", links, err
}
return string(bytes), links, nil
}
func pathExists(p string) bool {
exists := true
if _, err := os.Stat(p); os.IsNotExist(err) {
exists = false
}
return exists
}
func pathIsDir(p string) bool {
info, err := os.Stat(p)
if err != nil {
return false
}
return info.IsDir()
}

677
main.go
View File

@ -1,402 +1,100 @@
package main
// Bombadillo is an internet client for the terminal of unix or
// unix-like systems.
//
// Copyright (C) 2019 Brian Evans
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import (
"flag"
"fmt"
"io/ioutil"
"os"
"os/user"
"regexp"
"strconv"
"os/signal"
"path/filepath"
"strings"
"syscall"
"tildegit.org/sloum/bombadillo/cmdparse"
"tildegit.org/sloum/bombadillo/config"
"tildegit.org/sloum/bombadillo/cui"
"tildegit.org/sloum/bombadillo/gopher"
_ "tildegit.org/sloum/bombadillo/gemini"
)
var helplocation string = "gopher://colorfield.space:70/1/bombadillo-info"
var history gopher.History = gopher.MakeHistory()
var screen *cui.Screen
var userinfo, _ = user.Current()
var version string
var build string
var bombadillo *client
var helplocation string = "gopher://bombadillo.colorfield.space:70/1/user-guide.map"
var settings config.Config
var options = map[string]string{
"homeurl": "gopher://colorfield.space:70/1/bombadillo-info",
"savelocation": userinfo.HomeDir,
"searchengine": "gopher://gopher.floodgap.com:70/7/v2/vs",
"openhttp": "false",
"httpbrowser": "lynx",
}
func saveFile(address, name string) error {
quickMessage("Saving file...", false)
url, err := gopher.MakeUrl(address)
if err != nil {
quickMessage("Saving file...", true)
return err
}
data, err := gopher.Retrieve(url)
if err != nil {
quickMessage("Saving file...", true)
return err
}
err = ioutil.WriteFile(options["savelocation"]+name, data, 0644)
if err != nil {
quickMessage("Saving file...", true)
return err
}
quickMessage(fmt.Sprintf("Saved file to %s%s", options["savelocation"], name), false)
return nil
}
func saveFileFromData(v gopher.View) error {
quickMessage("Saving file...", false)
urlsplit := strings.Split(v.Address.Full, "/")
filename := urlsplit[len(urlsplit)-1]
saveMsg := fmt.Sprintf("Saved file as %q", options["savelocation"]+filename)
err := ioutil.WriteFile(options["savelocation"]+filename, []byte(strings.Join(v.Content, "")), 0644)
if err != nil {
quickMessage("Saving file...", true)
return err
}
quickMessage(saveMsg, false)
return nil
}
func search(u string) error {
cui.MoveCursorTo(screen.Height-1, 0)
cui.Clear("line")
fmt.Print("Enter form input: ")
cui.MoveCursorTo(screen.Height-1, 17)
entry, err := cui.GetLine()
if err != nil {
return err
}
quickMessage("Searching...", false)
searchurl := fmt.Sprintf("%s\t%s", u, entry)
sv, err := gopher.Visit(searchurl, options["openhttp"])
if err != nil {
quickMessage("Searching...", true)
return err
}
history.Add(sv)
quickMessage("Searching...", true)
updateMainContent()
screen.Windows[0].Scrollposition = 0
screen.ReflashScreen(true)
return nil
}
func routeInput(com *cmdparse.Command) error {
var err error
switch com.Type {
case cmdparse.SIMPLE:
err = simpleCommand(com.Action)
case cmdparse.GOURL:
err = goToURL(com.Target)
case cmdparse.GOLINK:
err = goToLink(com.Target)
case cmdparse.DO:
err = doCommand(com.Action, com.Value)
case cmdparse.DOLINK:
err = doLinkCommand(com.Action, com.Target)
case cmdparse.DOAS:
err = doCommandAs(com.Action, com.Value)
case cmdparse.DOLINKAS:
err = doLinkCommandAs(com.Action, com.Target, com.Value)
default:
return fmt.Errorf("Unknown command entry!")
}
return err
}
func toggleBookmarks() {
bookmarks := screen.Windows[1]
main := screen.Windows[0]
if bookmarks.Show {
bookmarks.Show = false
screen.Activewindow = 0
main.Active = true
bookmarks.Active = false
} else {
bookmarks.Show = true
screen.Activewindow = 1
main.Active = false
bookmarks.Active = true
}
screen.ReflashScreen(false)
}
func simpleCommand(a string) error {
a = strings.ToUpper(a)
switch a {
case "Q", "QUIT":
cui.Exit()
case "H", "HOME":
return goHome()
case "B", "BOOKMARKS":
toggleBookmarks()
case "SEARCH":
return search(options["searchengine"])
case "HELP", "?":
return goToURL(helplocation)
default:
return fmt.Errorf("Unknown action %q", a)
}
return nil
}
func goToURL(u string) error {
if num, _ := regexp.MatchString(`^-?\d+.?\d*$`, u); num {
return goToLink(u)
}
quickMessage("Loading...", false)
v, err := gopher.Visit(u, options["openhttp"])
if err != nil {
quickMessage("Loading...", true)
return err
}
quickMessage("Loading...", true)
if v.Address.Gophertype == "7" {
err := search(v.Address.Full)
if err != nil {
return err
}
} else if v.Address.IsBinary {
return saveFileFromData(v)
} else {
history.Add(v)
}
updateMainContent()
screen.Windows[0].Scrollposition = 0
screen.ReflashScreen(true)
return nil
}
func goToLink(l string) error {
if num, _ := regexp.MatchString(`^-?\d+$`, l); num && history.Length > 0 {
linkcount := len(history.Collection[history.Position].Links)
item, _ := strconv.Atoi(l)
if item <= linkcount && item > 0 {
linkurl := history.Collection[history.Position].Links[item-1]
quickMessage("Loading...", false)
v, err := gopher.Visit(linkurl, options["openhttp"])
if err != nil {
quickMessage("Loading...", true)
return err
}
quickMessage("Loading...", true)
if v.Address.Gophertype == "7" {
err := search(linkurl)
if err != nil {
return err
}
} else if v.Address.IsBinary {
return saveFileFromData(v)
} else {
history.Add(v)
}
} else {
return fmt.Errorf("Invalid link id: %s", l)
}
} else {
return fmt.Errorf("Invalid link id: %s", l)
}
updateMainContent()
screen.Windows[0].Scrollposition = 0
screen.ReflashScreen(true)
return nil
}
func goHome() error {
if options["homeurl"] != "unset" {
return goToURL(options["homeurl"])
}
return fmt.Errorf("No home address has been set")
}
func doLinkCommand(action, target string) error {
num, err := strconv.Atoi(target)
if err != nil {
return fmt.Errorf("Expected number, got %q", target)
}
switch action {
case "DELETE", "D":
err := settings.Bookmarks.Del(num)
if err != nil {
return err
}
screen.Windows[1].Content = settings.Bookmarks.List()
err = saveConfig()
if err != nil {
return err
}
screen.ReflashScreen(false)
return nil
case "BOOKMARKS", "B":
if num > len(settings.Bookmarks.Links)-1 {
return fmt.Errorf("There is no bookmark with ID %d", num)
}
err := goToURL(settings.Bookmarks.Links[num])
return err
}
return fmt.Errorf("This method has not been built")
}
func doCommandAs(action string, values []string) error {
if len(values) < 2 {
return fmt.Errorf("%q", values)
}
if values[0] == "." {
values[0] = history.Collection[history.Position].Address.Full
}
switch action {
case "ADD", "A":
err := settings.Bookmarks.Add(values)
if err != nil {
return err
}
screen.Windows[1].Content = settings.Bookmarks.List()
err = saveConfig()
if err != nil {
return err
}
screen.ReflashScreen(false)
return nil
case "WRITE", "W":
return saveFile(values[0], strings.Join(values[1:], " "))
case "SET", "S":
if _, ok := options[values[0]]; ok {
options[values[0]] = strings.Join(values[1:], " ")
return saveConfig()
}
return fmt.Errorf("Unable to set %s, it does not exist", values[0])
}
return fmt.Errorf("Unknown command structure")
}
func doCommand(action string, values []string) error {
if length := len(values); length != 1 {
return fmt.Errorf("Expected 1 argument, received %d", length)
}
switch action {
case "CHECK", "C":
err := checkConfigValue(values[0])
if err != nil {
return err
}
return nil
}
return fmt.Errorf("Unknown command structure")
}
func checkConfigValue(setting string) error {
if val, ok := options[setting]; ok {
quickMessage(fmt.Sprintf("%s is set to: %q", setting, val), false)
return nil
}
return fmt.Errorf("Unable to check %q, it does not exist", setting)
}
func doLinkCommandAs(action, target string, values []string) error {
num, err := strconv.Atoi(target)
if err != nil {
return fmt.Errorf("Expected number, got %q", target)
}
links := history.Collection[history.Position].Links
if num >= len(links) {
return fmt.Errorf("Invalid link id: %s", target)
}
switch action {
case "ADD", "A":
newBookmark := append([]string{links[num-1]}, values...)
err := settings.Bookmarks.Add(newBookmark)
if err != nil {
return err
}
screen.Windows[1].Content = settings.Bookmarks.List()
err = saveConfig()
if err != nil {
return err
}
screen.ReflashScreen(false)
return nil
case "WRITE", "W":
return saveFile(links[num-1], strings.Join(values, " "))
}
return fmt.Errorf("This method has not been built")
}
func updateMainContent() {
screen.Windows[0].Content = history.Collection[history.Position].Content
screen.Bars[0].SetMessage(history.Collection[history.Position].Address.Full)
}
func clearInput(incError bool) {
cui.MoveCursorTo(screen.Height-1, 0)
cui.Clear("line")
if incError {
cui.MoveCursorTo(screen.Height, 0)
cui.Clear("line")
}
}
func quickMessage(msg string, clearMsg bool) {
xPos := screen.Width - 2 - len(msg)
if xPos < 2 {
xPos = 2
}
cui.MoveCursorTo(screen.Height, xPos)
if clearMsg {
cui.Clear("right")
} else {
fmt.Print("\033[48;5;21m\033[38;5;15m", msg, "\033[0m")
}
}
func saveConfig() error {
bkmrks := settings.Bookmarks.IniDump()
opts := "\n[SETTINGS]\n"
for k, v := range options {
opts += k
opts += "="
opts += v
opts += "\n"
var opts strings.Builder
bkmrks := bombadillo.BookMarks.IniDump()
certs := bombadillo.Certs.IniDump()
opts.WriteString("\n[SETTINGS]\n")
for k, v := range bombadillo.Options {
if k == "theme" && v != "normal" && v != "inverse" {
v = "normal"
bombadillo.Options["theme"] = "normal"
}
opts.WriteString(k)
opts.WriteRune('=')
opts.WriteString(v)
opts.WriteRune('\n')
}
return ioutil.WriteFile(userinfo.HomeDir+"/.bombadillo.ini", []byte(bkmrks+opts), 0644)
opts.WriteString(bkmrks)
opts.WriteString(certs)
return ioutil.WriteFile(filepath.Join(bombadillo.Options["configlocation"], ".bombadillo.ini"), []byte(opts.String()), 0644)
}
func validateOpt(opt, val string) bool {
var validOpts = map[string][]string{
"webmode": []string{"none", "gui", "lynx", "w3m", "elinks"},
"theme": []string{"normal", "inverse"},
}
opt = strings.ToLower(opt)
val = strings.ToLower(val)
if _, ok := validOpts[opt]; ok {
for _, item := range validOpts[opt] {
if item == val {
return true
}
}
return false
}
return true
}
func lowerCaseOpt(opt, val string) string {
switch opt {
case "webmode", "theme":
return strings.ToLower(val)
default:
return val
}
}
func loadConfig() error {
file, err := os.Open(userinfo.HomeDir + "/.bombadillo.ini")
file, err := os.Open(bombadillo.Options["configlocation"] + "/.bombadillo.ini")
if err != nil {
err = saveConfig()
if err != nil {
@ -406,165 +104,122 @@ func loadConfig() error {
confparser := config.NewParser(file)
settings, _ = confparser.Parse()
file.Close()
screen.Windows[1].Content = settings.Bookmarks.List()
_ = file.Close()
for _, v := range settings.Settings {
lowerkey := strings.ToLower(v.Key)
if _, ok := options[lowerkey]; ok {
options[lowerkey] = v.Value
if lowerkey == "configlocation" {
// The config defaults to the home folder.
// Users cannot really edit this value. But
// a compile time override is available.
// It is still stored in the ini and as a part
// of the options map.
continue
}
if _, ok := bombadillo.Options[lowerkey]; ok {
if validateOpt(lowerkey, v.Value) {
bombadillo.Options[lowerkey] = v.Value
} else {
bombadillo.Options[lowerkey] = defaultOptions[lowerkey]
}
}
}
for i, v := range settings.Bookmarks.Titles {
_, _ = bombadillo.BookMarks.Add([]string{v, settings.Bookmarks.Links[i]})
}
for _, v := range settings.Certs {
bombadillo.Certs.Add(v.Key, v.Value)
}
return nil
}
func toggleActiveWindow() {
if screen.Windows[1].Show {
if screen.Windows[0].Active {
screen.Windows[0].Active = false
screen.Windows[1].Active = true
screen.Activewindow = 1
} else {
screen.Windows[0].Active = true
screen.Windows[1].Active = false
screen.Activewindow = 0
}
screen.Windows[1].DrawWindow()
}
}
func displayError(err error) {
cui.MoveCursorTo(screen.Height, 0)
fmt.Print("\033[41m\033[37m", err, "\033[0m")
}
func initClient() error {
history.Position = -1
screen = cui.NewScreen()
cui.SetCharMode()
screen.AddWindow(2, 1, screen.Height-2, screen.Width, false, false, true)
screen.Windows[0].Active = true
screen.AddMsgBar(1, " ((( Bombadillo ))) ", " A fun gopher client!", true)
bookmarksWidth := 40
if screen.Width < 40 {
bookmarksWidth = screen.Width
bombadillo = MakeClient(" ((( Bombadillo ))) ")
err := loadConfig()
if bombadillo.Options["tlscertificate"] != "" && bombadillo.Options["tlskey"] != "" {
bombadillo.Certs.LoadCertificate(bombadillo.Options["tlscertificate"], bombadillo.Options["tlskey"])
}
screen.AddWindow(2, screen.Width-bookmarksWidth, screen.Height-2, screen.Width, false, true, false)
return loadConfig()
return err
}
func handleResize() {
oldh, oldw := screen.Height, screen.Width
screen.GetSize()
if screen.Height != oldh || screen.Width != oldw {
screen.Windows[0].Box.Row2 = screen.Height - 2
screen.Windows[0].Box.Col2 = screen.Width
bookmarksWidth := 40
if screen.Width < 40 {
bookmarksWidth = screen.Width
// In the event of specific signals, ensure the display is shown correctly.
// Accepts a signal, blocking until it is received. Once not blocked, corrects
// terminal display settings as appropriate for that signal. Loops
// indefinitely, does not return.
func handleSignals(c <-chan os.Signal) {
for {
switch <-c {
case syscall.SIGTSTP:
cui.CleanupTerm()
_ = syscall.Kill(syscall.Getpid(), syscall.SIGSTOP)
case syscall.SIGCONT:
cui.InitTerm()
bombadillo.Draw()
case syscall.SIGINT:
cui.Exit()
}
screen.Windows[1].Box.Row2 = screen.Height - 2
screen.Windows[1].Box.Col1 = screen.Width - bookmarksWidth
screen.Windows[1].Box.Col2 = screen.Width
screen.DrawAllWindows()
screen.DrawMsgBars()
screen.ClearCommandArea()
}
}
//printHelp produces a nice display message when the --help flag is used
func printHelp() {
art := `Bombadillo - a non-web browser
Syntax: bombadillo [url]
bombadillo [options...]
Examples: bombadillo gopher://bombadillo.colorfield.space
bombadillo -v
Options:
`
_, _ = fmt.Fprint(os.Stdout, art)
flag.PrintDefaults()
}
func main() {
cui.HandleAlternateScreen("smcup")
getVersion := flag.Bool("v", false, "Display version information and exit")
flag.Usage = printHelp
flag.Parse()
if *getVersion {
fmt.Printf("Bombadillo %s - build %s\n", version, build)
os.Exit(0)
}
args := flag.Args()
cui.InitTerm()
defer cui.Exit()
err := initClient()
if err != nil {
// if we can't initialize the window,
// we can't do anything!
// if we can't initialize we should bail out
panic(err)
}
mainWindow := screen.Windows[0]
// watch for signals, send them to be handled
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGTSTP, syscall.SIGCONT, syscall.SIGINT)
go handleSignals(c)
if len(os.Args) > 1 {
err = goToURL(os.Args[1])
// Start polling for terminal size changes
go bombadillo.GetSize()
if len(args) > 0 {
// If a url was passed, move it down the line
// Goroutine so keypresses can be made during
// page load
bombadillo.Visit(args[0])
} else {
err = goHome()
}
if err != nil {
displayError(err)
} else {
updateMainContent()
// Otherwise, load the homeurl
// Goroutine so keypresses can be made during
// page load
bombadillo.Visit(bombadillo.Options["homeurl"])
}
// Loop indefinitely on user input
for {
c := cui.Getch()
handleResize()
switch c {
case 'j', 'J':
screen.Windows[screen.Activewindow].ScrollDown()
screen.ReflashScreen(false)
case 'k', 'K':
screen.Windows[screen.Activewindow].ScrollUp()
screen.ReflashScreen(false)
case 'q', 'Q':
cui.Exit()
case 'g':
screen.Windows[screen.Activewindow].ScrollHome()
screen.ReflashScreen(false)
case 'G':
screen.Windows[screen.Activewindow].ScrollEnd()
screen.ReflashScreen(false)
case 'd':
screen.Windows[screen.Activewindow].PageDown()
screen.ReflashScreen(false)
case 'u':
screen.Windows[screen.Activewindow].PageUp()
screen.ReflashScreen(false)
case 'b':
success := history.GoBack()
if success {
mainWindow.Scrollposition = 0
updateMainContent()
screen.ReflashScreen(true)
}
case 'B':
toggleBookmarks()
case 'f', 'F':
success := history.GoForward()
if success {
mainWindow.Scrollposition = 0
updateMainContent()
screen.ReflashScreen(true)
}
case '\t':
toggleActiveWindow()
case ':', ' ':
cui.MoveCursorTo(screen.Height-1, 0)
entry, err := cui.GetLine()
if err != nil {
displayError(err)
}
// Clear entry line and error line
clearInput(true)
if entry == "" {
continue
}
parser := cmdparse.NewParser(strings.NewReader(entry))
p, err := parser.Parse()
if err != nil {
displayError(err)
} else {
err := routeInput(p)
if err != nil {
displayError(err)
}
}
}
bombadillo.TakeControlInput()
}
}

View File

@ -1,60 +0,0 @@
TODO
- Add styles/color support
- Add code comments/documentation for all items
- Make sure html links using the URL convention work correctly
Control keys/input:
q quit
j scrolldown
k scrollup
f toggle showing favorites as subwindow
TODO - r refresh current page data (re-request)
GO
:# go to link num
:url go to url
SIMPLE
:quit quit
:home visit home
:bookmarks toogle bookmarks window
:search
:help
DOLINK
:delete # delete bookmark with num
:bookmarks # visit bookmark with num
DOLINKAS
:write # name write linknum to file
:add # name add link num as favorite
DOAS
:write url name write url to file
:add url name add link url as favorite
:set something something set a system variable
value, action, word
- - - - - - - - - - - - - - - - - -
Config format:
[favorites]
colorfield.space ++ gopher://colorfield.space:70/
My phlog ++ gopher://circumlunar.space/1/~sloum/
[options]
home ++ gopher://sdf.org
searchengine ++ gopher://floodgap.place/v2/veronicasomething
savelocation ++ ~/Downloads/
httpbrowser ++ lynx
openhttp ++ true

107
page.go Normal file
View File

@ -0,0 +1,107 @@
package main
import (
"strings"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
// Page represents a visited URL's contents; including
// the raw content, wrapped content, link slice, URL,
// and the current scroll position
type Page struct {
WrappedContent []string
RawContent string
Links []string
Location Url
ScrollPosition int
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
// ScrollPositionRange may not be in actual usage....
// TODO: find where this is being used
func (p *Page) ScrollPositionRange(termHeight int) (int, int) {
termHeight -= 3
if len(p.WrappedContent)-p.ScrollPosition < termHeight {
p.ScrollPosition = len(p.WrappedContent) - termHeight
}
if p.ScrollPosition < 0 {
p.ScrollPosition = 0
}
var end int
if len(p.WrappedContent) < termHeight {
end = len(p.WrappedContent)
} else {
end = p.ScrollPosition + termHeight
}
return p.ScrollPosition, end
}
// WrapContent performs a hard wrap to the requested
// width and updates the WrappedContent
// of the Page struct width a string slice
// of the wrapped data
func (p *Page) WrapContent(width int) {
counter := 0
var content strings.Builder
escape := false
content.Grow(len(p.RawContent))
for _, ch := range []rune(p.RawContent) {
if escape {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') {
escape = false
}
continue
}
if ch == '\n' {
content.WriteRune(ch)
counter = 0
} else if ch == '\t' {
if counter+4 < width {
content.WriteString(" ")
counter += 4
} else {
content.WriteRune('\n')
counter = 0
}
} else if ch == '\r' || ch == '\v' || ch == '\b' || ch == '\f' || ch == '\a' {
// Get rid of control characters we dont want
continue
} else if ch == 27 {
escape = true
continue
} else {
if counter < width {
content.WriteRune(ch)
counter++
} else {
content.WriteRune('\n')
counter = 0
if p.Location.Mime == "1" {
spacer := " "
content.WriteString(spacer)
counter += len(spacer)
}
content.WriteRune(ch)
}
}
}
p.WrappedContent = strings.Split(content.String(), "\n")
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
// MakePage returns a Page struct with default values
func MakePage(url Url, content string, links []string) Page {
p := Page{make([]string, 0), content, links, url, 0}
return p
}

98
pages.go Normal file
View File

@ -0,0 +1,98 @@
package main
import (
"fmt"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
// Pages is a struct that represents the history of the client.
// It functions as a container for the pages (history array) and
// tracks the current history length and location.
type Pages struct {
Position int
Length int
History [20]Page
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
// NavigateHistory takes a positive or negative integer
// and updates the current history position. Checks are done
// to make sure that the position moved to is a valid history
// location. Returns an error or nil.
func (p *Pages) NavigateHistory(qty int) error {
newPosition := p.Position + qty
if newPosition < 0 {
return fmt.Errorf("You are already at the beginning of history")
} else if newPosition > p.Length-1 {
return fmt.Errorf("Your way is blocked by void, there is nothing forward")
}
p.Position = newPosition
return nil
}
// Add gets passed a Page, which gets added to the history
// arrayr. Add also updates the current length and position
// of the Pages struct to which it belongs. Add also shifts
// off array items if necessary.
func (p *Pages) Add(pg Page) {
if p.Position == p.Length-1 && p.Length < len(p.History) {
p.History[p.Length] = pg
p.Length++
p.Position++
} else if p.Position == p.Length-1 && p.Length == 20 {
for x := 1; x < len(p.History); x++ {
p.History[x-1] = p.History[x]
}
p.History[len(p.History)-1] = pg
} else {
p.Position++
p.Length = p.Position + 1
p.History[p.Position] = pg
}
}
// Render wraps the content for the current page and returns
// the page content as a string slice
func (p *Pages) Render(termHeight, termWidth int) []string {
if p.Length < 1 {
return make([]string, 0)
}
pos := p.History[p.Position].ScrollPosition
prev := len(p.History[p.Position].WrappedContent)
p.History[p.Position].WrapContent(termWidth)
now := len(p.History[p.Position].WrappedContent)
if prev > now {
diff := prev - now
pos = pos - diff
} else if prev < now {
diff := now - prev
pos = pos + diff
if pos > now-termHeight {
pos = now - termHeight
}
}
if pos < 0 || now < termHeight-3 {
pos = 0
}
p.History[p.Position].ScrollPosition = pos
return p.History[p.Position].WrappedContent[pos:]
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
// MakePages returns a Pages struct with default values
func MakePages() Pages {
return Pages{-1, 0, [20]Page{}}
}

36
telnet/telnet.go Normal file
View File

@ -0,0 +1,36 @@
// Package telnet provides a function that starts a telnet session in a subprocess.
package telnet
import (
"fmt"
"os"
"os/exec"
"tildegit.org/sloum/bombadillo/cui"
)
// StartSession starts a telnet session as a subprocess, connecting to the host
// and port specified. Telnet is run interactively as a subprocess until the
// process ends. It returns any errors from the telnet session.
func StartSession(host string, port string) (string, error) {
c := exec.Command("telnet", host, port)
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
// Clear the screen and position the cursor at the top left
fmt.Print("\033[2J\033[0;0H")
// Defer reset and reinit of the terminal to prevent any changes from
// telnet carrying over to the client (or beyond...)
defer func() {
cui.Tput("reset")
cui.InitTerm()
}()
err := c.Run()
if err != nil {
return "", fmt.Errorf("Telnet error response: %s", err.Error())
}
return "Telnet session terminated", nil
}

172
url.go Normal file
View File

@ -0,0 +1,172 @@
package main
import (
"fmt"
"os/user"
"path/filepath"
"regexp"
"strings"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
// Url is a struct representing the different pieces
// of a url. This custom struct is used rather than the
// built-in url library so-as to support gopher URLs, as
// well as track mime-type and renderability (can the
// response to the url be rendered as text in the client).
type Url struct {
Scheme string
Host string
Port string
Resource string
Full string
Mime string
DownloadOnly bool
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
// There are currently no receivers for the Url struct
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
// MakeUrl is a Url constructor that takes in a string
// representation of a url and returns a Url struct and
// an error (or nil).
func MakeUrl(u string) (Url, error) {
if len(u) < 1 {
return Url{}, fmt.Errorf("Invalid url, unable to parse")
}
if strings.HasPrefix(u, "finger://") {
return parseFinger(u)
}
var out Url
if local := strings.HasPrefix(u, "local://"); u[0] == '/' || u[0] == '.' || u[0] == '~' || local {
if local && len(u) > 8 {
u = u[8:]
}
var home string
userinfo, err := user.Current()
if err != nil {
home = ""
} else {
home = userinfo.HomeDir
}
u = strings.Replace(u, "~", home, 1)
res, err := filepath.Abs(u)
if err != nil {
return out, fmt.Errorf("Invalid path, unable to parse")
}
out.Scheme = "local"
out.Host = ""
out.Port = ""
out.Mime = ""
out.Resource = res
out.Full = out.Scheme + "://" + out.Resource
return out, nil
}
re := regexp.MustCompile(`^((?P<scheme>[a-zA-Z]+):\/\/)?(?P<host>[\w\-\.\d]+)(?::(?P<port>\d+)?)?(?:/(?P<type>[01345679gIhisp])?)?(?P<resource>.*)?$`)
match := re.FindStringSubmatch(u)
if valid := re.MatchString(u); !valid {
return out, fmt.Errorf("Invalid url, unable to parse")
}
for i, name := range re.SubexpNames() {
switch name {
case "scheme":
out.Scheme = match[i]
case "host":
out.Host = match[i]
case "port":
out.Port = match[i]
case "type":
out.Mime = match[i]
case "resource":
out.Resource = match[i]
}
}
if out.Host == "" {
return out, fmt.Errorf("no host")
}
out.Scheme = strings.ToLower(out.Scheme)
if out.Scheme == "" {
out.Scheme = "gopher"
}
if out.Scheme == "gopher" && out.Port == "" {
out.Port = "70"
} else if out.Scheme == "http" && out.Port == "" {
out.Port = "80"
} else if out.Scheme == "https" && out.Port == "" {
out.Port = "443"
} else if out.Scheme == "gemini" && out.Port == "" {
out.Port = "1965"
} else if out.Scheme == "telnet" && out.Port == "" {
out.Port = "23"
}
if out.Scheme == "gopher" {
if out.Mime == "" {
out.Mime = "1"
}
if out.Resource == "" || out.Resource == "/" {
out.Mime = "1"
}
if out.Mime == "7" && strings.Contains(out.Resource, "\t") {
out.Mime = "1"
}
switch out.Mime {
case "1", "0", "h", "7":
out.DownloadOnly = false
default:
out.DownloadOnly = true
}
} else {
out.Resource = fmt.Sprintf("%s%s", out.Mime, out.Resource)
out.Mime = ""
}
out.Full = out.Scheme + "://" + out.Host + ":" + out.Port + "/" + out.Mime + out.Resource
return out, nil
}
func parseFinger(u string) (Url, error) {
var out Url
out.Scheme = "finger"
if len(u) < 10 {
return out, fmt.Errorf("Invalid finger address")
}
u = u[9:]
userPlusAddress := strings.Split(u, "@")
if len(userPlusAddress) > 1 {
out.Resource = userPlusAddress[0]
u = userPlusAddress[1]
}
hostPort := strings.Split(u, ":")
if len(hostPort) < 2 {
out.Port = "79"
} else {
out.Port = hostPort[1]
}
out.Host = hostPort[0]
resource := ""
if out.Resource != "" {
resource = out.Resource + "@"
}
out.Full = fmt.Sprintf("%s://%s%s:%s", out.Scheme, resource, out.Host, out.Port)
return out, nil
}