├── .gitignore ├── screenshots ├── ncgopher.png └── ncgopher-darkmode.png ├── src ├── ui │ ├── mod.rs │ ├── statusbar.rs │ ├── layout.rs │ ├── setup.rs │ └── dialogs.rs ├── themes │ ├── README │ ├── lightmode.toml │ └── darkmode.toml ├── about │ ├── license_header.gmi │ ├── sites.gmi │ ├── release-notes.gmi │ └── help.gmi ├── help.txt ├── url_tools.rs ├── bookmarks.rs ├── certificates.rs ├── main.rs ├── history.rs ├── gemini.rs ├── clientcertificates.rs ├── gophermap.rs └── settings.rs ├── AUTHORS ├── .github ├── dependabot.yml ├── workflows │ └── ci.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── Makefile ├── LICENSE ├── CONTRIBUTING.md ├── Cargo.toml ├── .travis.yml ├── ncgopher.1 ├── ROADMAP.md ├── README.md ├── CHANGELOG └── Cargo.lock /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | *.swp 3 | -------------------------------------------------------------------------------- /screenshots/ncgopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jansc/ncgopher/HEAD/screenshots/ncgopher.png -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod dialogs; 2 | pub mod layout; 3 | pub mod setup; 4 | pub mod statusbar; 5 | -------------------------------------------------------------------------------- /screenshots/ncgopher-darkmode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jansc/ncgopher/HEAD/screenshots/ncgopher-darkmode.png -------------------------------------------------------------------------------- /src/themes/README: -------------------------------------------------------------------------------- 1 | The themes in this directory are compiled into the binary. 2 | Support for custom themes will be added soon. 3 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Jan Schreiber (https://github.com/jansc) 2 | Edd Barrett (https://github.com/vext01) 3 | Johann150 (https://github.com/Johann150) 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | -------------------------------------------------------------------------------- /src/about/license_header.gmi: -------------------------------------------------------------------------------- 1 | ncgopher is distributed under the BSD 2-Clause license. 2 | => https://opensource.org/licenses/BSD-2-Clause more about the BSD 2-Clause license 3 | => https://github.com/jansc/ncgopher ncgopher Git Repository 4 | 5 | Here is the BSD 2-Clause license: 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Install dependencies 15 | run: sudo apt update && sudo apt install build-essential pkg-config libncurses-dev libsqlite3-dev 16 | - uses: actions/cache@v4 17 | with: 18 | path: | 19 | ~/.cargo/registry 20 | ~/.cargo/git 21 | target 22 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 23 | - name: cargo check 24 | run: cargo check 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. FreeBSD] 28 | - Version [e.g. 12.1] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /src/about/sites.gmi: -------------------------------------------------------------------------------- 1 | # Some pointers 2 | 3 | Here are some sites you might find interesting for a start. Remember you can use the i key to inspect any selected link. The URL which is linked will then be shown in the status bar at the bottom. 4 | 5 | ## Gopher 6 | 7 | => gopher://gopher.floodgap.com/7/v2/vs Floodgap, also hosting Veronica/2 search 8 | => gopher://gopherpedia.com/ Search Gopherpedia 9 | => gopher://perso.pw/ Search OpenBSD man pages 10 | => gopher://me0w.net/ Some nice services like pastebin, web search with searx etc. 11 | => gopher://jan.bio/ Jan's Gopherhole with the NCGOPHER USER GUIDE and Gopher Movie Database 12 | 13 | ## Gemini 14 | 15 | => gemini://gemini.circumlunar.space/ More authoritative about Gemini (also has a list of known Gemini servers!) 16 | => gemini://gus.guru/search Search with GUS 17 | => gemini://houston.coder.town/search Search with Houston 18 | => gemini://jan.bio Jan's gemini site 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CARGO := cargo 2 | BINARY := ncgopher 3 | PREFIX := /usr/local 4 | EXEC_PREFIX := ${PREFIX} 5 | BINDIR := ${EXEC_PREFIX}/bin 6 | DATAROOTDIR := ${PREFIX}/share 7 | MANDIR := ${DATAROOTDIR}/man 8 | MAN1DIR := ${MANDIR}/man1 9 | 10 | .PHONY: build 11 | build: 12 | ${CARGO} build --release 13 | 14 | .PHONY: install 15 | install: install-bin install-man clean 16 | 17 | .PHONY: install-man 18 | install-man: ncgopher.1 19 | gzip -k ./ncgopher.1 20 | install -d ${DESTDIR}${MAN1DIR} 21 | install -m 0644 ./ncgopher.1.gz ${DESTDIR}${MAN1DIR} 22 | 23 | .PHONY: install-bin 24 | install-bin: build 25 | install -d ${DESTDIR}${BINDIR} 26 | install -m 0755 ./target/release/${BINARY} ${DESTDIR}${BINDIR} 27 | 28 | .PHONY: clean 29 | clean: 30 | ${CARGO} clean 31 | rm -f ./ncgopher.1.gz 2> /dev/null 32 | 33 | .PHONY: uninstall 34 | uninstall: clean 35 | rm -f ${DESTDIR}${MAN1DIR}/ncgopher.1.gz 36 | rm -f ${DESTDIR}${BINDIR}/${BINARY} 37 | 38 | 39 | .PHONY: test 40 | test: clean build 41 | -------------------------------------------------------------------------------- /src/about/release-notes.gmi: -------------------------------------------------------------------------------- 1 | # Release notes 2 | 3 | ## 0.7.0 4 | 5 | Changes: 6 | * Upgraded dependecies 7 | 8 | New features: 9 | * Support for custom keybindings 10 | 11 | ## 0.6.0 12 | 13 | Changes: 14 | 15 | * Add MSYS2 MINGW64 terminal packages for Windows (pull request by Ari) 16 | * Made release-notes accessible from menu 17 | * Upgraded dependencies 18 | 19 | Bugfixes: 20 | 21 | * Fixes #305 Added 'vendored' feature to native-tls 22 | * Fixes #210. Remove double dot in gopher content 23 | 24 | 25 | ## 0.5.0 26 | 27 | New features: 28 | 29 | * Gemini TLS client certificate support 30 | * Added finger support 31 | * Setting for disabling history recording. NB. ncgopher will still save 32 | gemini certificate fingerprints and log some url info to the debug log 33 | if --debug switch is activated. Already recorded history will not be 34 | deleted. 35 | 36 | Bugfixes: 37 | 38 | * Got rid of screen flickering on redraw 39 | 40 | ## See CHANGELOG for changes before 0.5.0 41 | -------------------------------------------------------------------------------- /src/themes/lightmode.toml: -------------------------------------------------------------------------------- 1 | # Every field in a theme file is optional. 2 | 3 | shadow = false 4 | borders = "simple" # Alternatives are "none" and "outset" 5 | 6 | # Base colors are red, green, blue, 7 | # cyan, magenta, yellow, white and black. 8 | [colors] 9 | # There are 3 ways to select a color: 10 | # - The 16 base colors are selected by name: 11 | # "blue", "light red", "magenta", ... 12 | # - Low-resolution colors use 3 characters, each <= 5: 13 | # "541", "003", ... 14 | # - Full-resolution colors start with '#' and can be 3 or 6 hex digits: 15 | # "#1A6", "#123456", ... 16 | # If the value is an array, the first valid 17 | # and supported color will be used. 18 | 19 | background = ["#cdf6cd", "454", "magenta"] 20 | 21 | shadow = ["#222288", "blue"] 22 | view = ["444", "white"] 23 | 24 | primary = "black" 25 | secondary = "blue" 26 | tertiary = "#252521" 27 | 28 | title_primary = ["magenta"] 29 | title_secondary = "#ffff55" 30 | 31 | highlight = "red" 32 | highlight_inactive = "blue" 33 | -------------------------------------------------------------------------------- /src/themes/darkmode.toml: -------------------------------------------------------------------------------- 1 | # Every field in a theme file is optional. 2 | 3 | shadow = false 4 | borders = "simple" # Alternatives are "none" and "outset" 5 | 6 | # Base colors are red, green, blue, 7 | # cyan, magenta, yellow, white and black. 8 | [colors] 9 | # There are 3 ways to select a color: 10 | # - The 16 base colors are selected by name: 11 | # "blue", "light red", "magenta", ... 12 | # - Low-resolution colors use 3 characters, each <= 5: 13 | # "541", "003", ... 14 | # - Full-resolution colors start with '#' and can be 3 or 6 hex digits: 15 | # "#1A6", "#123456", ... 16 | 17 | background = "black" 18 | #shadow = ["#222288", "blue"] 19 | view = "black" 20 | 21 | # An array with a single value has the same effect as a simple value. 22 | primary = "blue" 23 | #secondary = "#EEEEEE" 24 | tertiary = "#252521" 25 | 26 | # Hex values can use lower or uppercase. 27 | # (base color MUST be lowercase) 28 | title_primary = ["magenta"] # `BLUE` will be skipped. 29 | #title_secondary = "#ffff55" 30 | 31 | # Lower precision values can use only 3 digits. 32 | highlight = "cyan" 33 | highlight_inactive = "cyan" 34 | highlight_text = "black" 35 | -------------------------------------------------------------------------------- /src/help.txt: -------------------------------------------------------------------------------- 1 | |------------+--------------------------------| 2 | | Key | Command | 3 | |------------+--------------------------------| 4 | | Arrow keys | Move around in text | 5 | | Enter | Open the link under the cursor | 6 | | Esc | Go to menubar | 7 | | Space | Scroll down one page | 8 | | g | Open new URL | 9 | | G | Edit current URL | 10 | | b | Navigate back | 11 | | q | Close application | 12 | | s | Save current page | 13 | | r | Reload current page | 14 | | i | Show link under cursor | 15 | | a | Add bookmark for current page | 16 | | l | Go to next link | 17 | | L | Go to previous link | 18 | | j | Move one line down | 19 | | k | Move one line up | 20 | | / | Search in text | 21 | | n | Move to next search result | 22 | | N | Move to previous search result | 23 | | ? | Display this help text | 24 | |------------+--------------------------------| 25 | 26 | TODO: Generate this from the keybindings 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2019-2022 The ncgopher Authors 4 | 5 | Parts of the status bar implementation are written by Henrik Friedrichsen 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, this 12 | list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, 15 | this list of conditions and the following disclaimer in the documentation 16 | and/or other materials provided with the distribution. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Issues 4 | 5 | Issues are very valuable to this project. 6 | 7 | * Ideas are a valuable source of contributions others can make 8 | * Problems show where this project is lacking 9 | * With a question you show where contributors can improve the user experience 10 | 11 | Thank you for creating them. 12 | 13 | ## Pull Requests 14 | 15 | Pull requests are, a great way to get your ideas into this repository. 16 | 17 | When deciding if I merge in a pull request I look at the following things: 18 | 19 | ### Does it state intent 20 | 21 | You should be clear which problem you're trying to solve with your contribution. 22 | 23 | For example: 24 | 25 | > Add link to code of conduct in README.md 26 | 27 | Doesn't tell me anything about why you're doing that 28 | 29 | > Add link to code of conduct in README.md because users don't always look in the CONTRIBUTING.md 30 | 31 | Tells me the problem that you have found, and the pull request shows me the action you have taken to solve it. 32 | 33 | 34 | ### Is it of good quality 35 | 36 | * There are no spelling mistakes 37 | * It reads well 38 | * For English language contributions: Has a good score on [Grammarly](grammarly.com) or [Hemingway App](http://www.hemingwayapp.com/) 39 | 40 | ### Does it move this repository closer to my vision for the repository 41 | 42 | The aim of this repository is: 43 | 44 | * To provide a terminal-based client for the slow web. 45 | * Make textual information on gopher and gemini easily accessible to users. 46 | * Foster a culture of respect and gratitude in the open source community. 47 | 48 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ncgopher" 3 | version = "0.7.0" 4 | authors = ["Jan Schreiber "] 5 | edition = "2018" 6 | license = "BSD-2-Clause" 7 | description = "An ncurses gopher and gemini client for the modern internet" 8 | documentation = "https://github.com/jansc/ncgopher/blob/master/README.org" 9 | readme = "README.md" 10 | homepage = "https://github.com/jansc/ncgopher" 11 | repository = "https://github.com/jansc/ncgopher" 12 | keywords = ["cli", "gopher", "gemini", "client"] 13 | categories = ["command-line-utilities", "network-programming"] 14 | exclude = ["screenshots/**"] 15 | 16 | [badges.travis-ci] 17 | repository = "jansc/ncgopher" 18 | 19 | [dependencies] 20 | backtrace = "0.3" 21 | unicode-width = "0.2.1" 22 | url = { version = "2.5", features = ["serde"] } 23 | urlencoding = "2.1.3" 24 | lazy_static = "1.5.0" 25 | clap = { version = "4.5.45", features = ["derive"] } 26 | log = { version = "0.4.22", features = ["std"] } 27 | dirs = "6.0.0" 28 | rcgen = "0.14" 29 | serde_derive = "1.0" 30 | serde = { version = "1.0", features = ["derive"] } 31 | toml = "0.8.19" 32 | rusqlite = { version = "0.37.0", features = ["url", "time"] } 33 | gemtext = "0.2.1" 34 | rustls = { version = "0.23.29", default-features = false, features = ["ring", "std"] } 35 | x509-parser = "0.17.0" 36 | ring = "0.17.8" 37 | time = { version = "0.3.41", features = ["serde", "serde-human-readable", "std", "formatting", "parsing"] } 38 | pancurses = "0.17.0" 39 | base64 = "0.22" 40 | percent-encoding = "2.3" 41 | idna = "1.0" 42 | cursive = { version = "0.21.1", default-features = false, features = ["pancurses-backend", "toml"] } 43 | crossbeam-channel = "0.5.15" 44 | regex = "1" 45 | mime = "0.3.17" 46 | linkify = "0.10.0" 47 | stringreader = "0.1.1" 48 | rustls-pemfile = "2.1.1" 49 | -------------------------------------------------------------------------------- /src/ui/statusbar.rs: -------------------------------------------------------------------------------- 1 | use cursive::theme::ColorStyle; 2 | use cursive::traits::View; 3 | use cursive::vec::Vec2; 4 | use cursive::Printer; 5 | use std::sync::{Arc, RwLock}; 6 | 7 | pub struct StatusBar { 8 | last_size: Vec2, 9 | message: Arc>, 10 | } 11 | 12 | impl StatusBar { 13 | pub fn new() -> StatusBar { 14 | StatusBar { 15 | last_size: Vec2::new(0, 0), 16 | message: Arc::new(RwLock::new(String::new())), 17 | } 18 | } 19 | 20 | pub fn get_message(&self) -> Arc> { 21 | self.message.clone() 22 | } 23 | } 24 | 25 | impl View for StatusBar { 26 | fn draw(&self, printer: &Printer<'_, '_>) { 27 | if printer.size.x == 0 { 28 | warn!("status bar height is zero"); 29 | return; 30 | } 31 | let msg = self.message.read().unwrap(); 32 | printer.with_color(ColorStyle::highlight_inactive(), |printer| { 33 | // clear line 34 | printer.print_hline((0, 0), printer.size.x, " "); 35 | // write content 36 | printer.print((1, 0), msg.as_str()); 37 | }); 38 | printer.with_color(ColorStyle::tertiary(), |printer|{ 39 | // clear line 40 | printer.print_hline((0, 1), printer.size.x, " "); 41 | // write content 42 | printer.print( 43 | (1, 1), 44 | "Commands: Use the arrow keys to move. 'b' for back, 'g' for open URL, 'ESC' for menu" 45 | ); 46 | }); 47 | } 48 | 49 | fn layout(&mut self, size: Vec2) { 50 | self.last_size = size; 51 | } 52 | 53 | fn required_size(&mut self, constraint: Vec2) -> Vec2 { 54 | Vec2::new(constraint.x, 2) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | sudo: true 3 | rust: 4 | - stable 5 | - beta 6 | - nightly 7 | 8 | # Cache `cargo install`ed tools, but don't cache the project's `target` 9 | # directory (which ends up over-caching and filling all disk space!) 10 | cache: cargo 11 | # directories: 12 | # - /home/travis/.cargo 13 | 14 | script: 15 | - cargo build --verbose --all 16 | - cargo test --verbose --all 17 | 18 | 19 | DEPLOY_TO_GITHUB: &DEPLOY_TO_GITHUB 20 | before_deploy: 21 | - git config --local user.name "Jan Schreiber" 22 | - git config --local user.email "jan@mecinus.com" 23 | - name="ncgopher-$TRAVIS_TAG-$TARGET" 24 | - mkdir $name 25 | - cp target/$TARGET/release/ncgopher $name/ 26 | - cp README.org LICENSE $name/ 27 | - tar czvf $name.tar.gz $name 28 | deploy: 29 | provider: releases 30 | api_key: $GH_TOKEN 31 | file: ncgopher-$TRAVIS_TAG-$TARGET.tar.gz 32 | skip_cleanup: true 33 | on: 34 | branch: master 35 | tags: true 36 | 37 | matrix: 38 | include: 39 | # Can't crosscompile due to error in ncurses-rs 40 | # - name: Linux Binary 41 | # env: TARGET=x86_64-unknown-linux-musl 42 | # rust: stable 43 | # before_script: 44 | # - rustup target add $TARGET 45 | # - sudo apt-get -y install libncurses5 libncursesw5 libncurses5-dev libncursesw5-dev git 46 | # script: cargo build --verbose --release --target $TARGET --locked 47 | # addons: 48 | # apt: 49 | # packages: 50 | # - musl-tools 51 | # <<: *DEPLOY_TO_GITHUB 52 | 53 | - name: macOS Binary 54 | env: MACOSX_DEPLOYMENT_TARGET=10.7 TARGET=x86_64-apple-darwin 55 | os: osx 56 | rust: stable 57 | script: cargo build --release --target $TARGET --locked 58 | install: true 59 | <<: *DEPLOY_TO_GITHUB 60 | 61 | # Testing other channels 62 | # Can't get compilation on windows to run here: 63 | # - name: Windows 64 | # env: TARGET=x86_64-pc-windows-msvc 65 | # # TODO: install mingw-w64-pdcurses 66 | # os: windows 67 | # rust: stable -------------------------------------------------------------------------------- /ncgopher.1: -------------------------------------------------------------------------------- 1 | .TH NCGOPHER 1 2 | .SH NAME 3 | ncgopher \- A gopher client for the modern internet. 4 | .SH SYNOPSIS 5 | .B ncgopher 6 | [\fBoptions\fR] 7 | .IR [url] 8 | .SH DESCRIPTION 9 | .B ncgopher 10 | is an ncurses gopher client written in Rust meant to 11 | be run in a terminal. 12 | .SH OPTIONS 13 | .TP 14 | .BR \-h ", " \-\-help\fR 15 | Show help and available options and exit. 16 | .TP 17 | .BR \-v ", " \-\-version\fR 18 | Print the version number of ncgopher and exit. 19 | .TP 20 | .BR [url]\fR 21 | Gopher URL to open on startup. 22 | .SH FILES 23 | The configuration is placed into the the users XDG 24 | configuration directory. On most systems this is 25 | .B ~/.config/ncgopher 26 | Three files are created in this directory the first 27 | time ncgopher is run: 28 | 29 | - config 30 | - history 31 | - bookmarks 32 | .SH BUGS 33 | Expect plenty. Please report bugs on this page: 34 | 35 | https://github.com/jansc/ncgopher/issues 36 | .SH KEYBOARD COMMANDS 37 | .TP 38 | .B 39 | Arrow keys 40 | Move around in text 41 | .TP 42 | .B 43 | Enter 44 | Open the link under the cursor 45 | .TP 46 | .B 47 | Esc 48 | Go to menubar 49 | .TP 50 | .B 51 | Space 52 | Scroll down one page 53 | .TP 54 | .B 55 | g 56 | Open new URL 57 | .TP 58 | .B 59 | G 60 | Edit current URL 61 | .TP 62 | .B 63 | b 64 | Navigate back 65 | .TP 66 | .B 67 | q 68 | Close application 69 | .TP 70 | .B 71 | i 72 | Show link under cursor 73 | .TP 74 | .B 75 | s 76 | Save current page 77 | .TP 78 | .B 79 | r 80 | Reload current page 81 | .TP 82 | .B 83 | a 84 | Add bookmark for current page 85 | 86 | .SH LINKS 87 | The source code of \fNncgopher\fP is available at github: 88 | .TP 89 | .B 90 | Source code repository 91 | https://github.com/jansc/ncgopher 92 | .TP 93 | .B 94 | Bug reports 95 | https://github.com/jansc/ncgopher/issues 96 | .SH AUTHORS 97 | .B ncgopher 98 | was written by Jan Schreiber with moral support from the 99 | kind people on #gopher on the tilde IRC channel. 100 | See the AUTHORS file in the distribution for a list of 101 | contributors. 102 | .SH LICENSE 103 | .B ncgopher 104 | is licensed under the BSD 2-Clause License. See the file 105 | .B LICENSE 106 | for more information. 107 | -------------------------------------------------------------------------------- /src/about/help.gmi: -------------------------------------------------------------------------------- 1 | # Welcome 2 | Welcome to ncgopher, an ncurses client for gemini, gopher and finger. You can use the following key commands: 3 | 4 | ``` 5 | |------------+--------------------------------| 6 | | Key | Command | 7 | |------------+--------------------------------| 8 | | Arrow keys | Move around in text | 9 | | Enter | Open the link under the cursor | 10 | | Esc | Go to menubar | 11 | | Space | Scroll down one page | 12 | | g | Open new URL | 13 | | G | Edit current URL | 14 | | b | Navigate back | 15 | | q | Close application | 16 | | s | Save current page | 17 | | r | Reload current page | 18 | | i | Show link under cursor | 19 | | a | Add bookmark for current page | 20 | | l | Go to next link | 21 | | L | Go to previous link | 22 | | j | Move one line down | 23 | | k | Move one line up | 24 | | / | Search in text | 25 | | n | Move to next search result | 26 | | N | Move to previous search result | 27 | | ? | Display this help text | 28 | |------------+--------------------------------| 29 | ``` 30 | 31 | # What is this? 32 | ncgopher is a browser for the gemini and the gopher protocols, sometimes also collectively known as the "small internet". 33 | 34 | => about:sites See some pages to start off. 35 | 36 | ## Gopher 37 | Gopher was deveolped in 1991 at the University of Minnesota, and named after the school's mascot. Gopher is a menu-driven interface that allows a user to browse for text information served off of various gopher servers. 38 | => gopher://gopherpedia.com:70/1/about some information about gopher (and gopherpedia) at gopherpedia, a Wikipedia mirror for the gopher protocol 39 | 40 | ## Gemini 41 | Gemini is a new application-level internet protocol for the distribution of arbitrary files, with some special consideration for serving a lightweight hypertext format which facilitates linking between files. 42 | => gemini://gemini.circumlunar.space/ more official resources about gemini 43 | And actually, the document you are seeing here is written in Gemini's text format. You can take a look at this page in the sources in src/about/help.gmi ! 44 | 45 | ## What do you mean "sources"? 46 | ncgopher is free (libre) and open source software licensed under the "2-clause BSD" or "FreeBSD" license. It is written in the Rust programming language. The source code is versioned in a git repository available through GitHub: 47 | => https://github.com/jansc/ncgopher 48 | If you have any issues or feel something important is missing, you can raise that as an issue there. Or feel free to contribute there as well! 49 | Copyright © 2019-2022 The ncgopher Authors. Parts of the status bar implementation are Copyright © 2019, Henrik Friedrichsen. 50 | -------------------------------------------------------------------------------- /src/url_tools.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use url::Url; 3 | 4 | pub fn normalize_domain(u: &mut Url) { 5 | use idna::domain_to_ascii; 6 | use percent_encoding::percent_decode_str; 7 | 8 | // remove default port number 9 | if u.port() == Some(1965) { 10 | u.set_port(None).expect("gemini URL without host"); 11 | } 12 | 13 | if let Some(domain) = u.domain() { 14 | // since the gemini scheme is not "special" according to the WHATWG spec 15 | // it will be percent-encoded by the url crate which has to be undone 16 | let domain = percent_decode_str(domain) 17 | .decode_utf8() 18 | .expect("could not decode percent-encoded url"); 19 | // reencode the domain as IDNA 20 | let domain = domain_to_ascii(&domain).expect("could not IDNA encode URL"); 21 | // make the url use the newly encoded domain name 22 | u.set_host(Some(&domain)).expect("error replacing host"); 23 | } else { 24 | log::info!("tried to reencode URL to IDNA that did not contain a domain name"); 25 | } 26 | } 27 | 28 | /// Transforms a URL back into its human readable Unicode representation. 29 | pub fn human_readable_url(url: &Url) -> String { 30 | match url.scheme() { 31 | // these schemes are considered "special" by the WHATWG spec 32 | // cf. https://url.spec.whatwg.org/#special-scheme 33 | "ftp" | "http" | "https" | "ws" | "wss" => { 34 | // first unescape the domain name from IDNA encoding 35 | let url_str = if let Some(domain) = url.domain() { 36 | let (domain, result) = idna::domain_to_unicode(domain); 37 | result.expect("could not decode idna domain"); 38 | let url_str = url.to_string(); 39 | // replace the IDNA encoded domain with the unescaped version 40 | // since the domain cannot contain percent signs we do not have 41 | // to worry about double unescaping later 42 | url_str.replace(url.host_str().unwrap(), &domain) 43 | } else { 44 | // must be using IP address 45 | url.to_string() 46 | }; 47 | // now unescape the rest of the URL 48 | percent_encoding::percent_decode_str(&url_str) 49 | .decode_utf8() 50 | .unwrap() 51 | .to_string() 52 | } 53 | _ => { 54 | // the domain and the path will be percent encoded 55 | // it is easiest to do it all at once 56 | percent_encoding::percent_decode_str(url.as_str()) 57 | .decode_utf8_lossy() 58 | .into_owned() 59 | } 60 | } 61 | } 62 | 63 | /// Returns a path into the configured download directory with either 64 | /// the file name in the Url 65 | pub fn download_filename_from_url(url: &Url) -> String { 66 | let download_path = crate::SETTINGS.read().unwrap().config.download_path.clone(); 67 | 68 | let filename = match url.path_segments() { 69 | Some(path_segments) => path_segments.last().unwrap_or_default(), 70 | None => "download", 71 | }; 72 | let filename = if filename.is_empty() { 73 | // FIXME: file extension based on mime type 74 | "download" 75 | } else { 76 | filename 77 | }; 78 | 79 | let path = Path::new(&download_path).join(filename); 80 | path.display().to_string() 81 | } 82 | -------------------------------------------------------------------------------- /src/bookmarks.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::collections::HashMap; 3 | use std::fs::read_to_string; 4 | use std::fs::File as FsFile; 5 | use std::io::Write; 6 | use std::path::PathBuf; 7 | use url::Url; 8 | 9 | #[derive(Clone, Debug, Serialize, Deserialize)] 10 | pub struct Bookmark { 11 | pub title: String, 12 | pub url: Url, 13 | pub tags: Vec, 14 | } 15 | 16 | #[derive(Clone, Debug, Serialize)] 17 | pub struct Bookmarks { 18 | /// All bookmarks 19 | pub entries: Vec, 20 | } 21 | 22 | impl Bookmarks { 23 | pub fn new() -> Bookmarks { 24 | let confdir = Bookmarks::get_bookmark_path(); 25 | println!("Looking for bookmarks file {:?}", confdir); 26 | let mut bookmarks_string = String::new(); 27 | if confdir.as_path().exists() { 28 | bookmarks_string = read_to_string(confdir).unwrap(); 29 | } 30 | println!("Reading bookmarks..."); 31 | let bookmarks_table: HashMap> = 32 | toml::from_str(&bookmarks_string).unwrap_or_default(); 33 | let entries: &[Bookmark] = match bookmarks_table.contains_key("bookmark") { 34 | true => &bookmarks_table["bookmark"], 35 | false => &[], 36 | }; 37 | 38 | Bookmarks { 39 | entries: entries.to_vec(), 40 | } 41 | } 42 | 43 | fn get_bookmark_path() -> PathBuf { 44 | let mut dir = dirs::config_dir().expect("no configuration directory"); 45 | dir.push(env!("CARGO_PKG_NAME")); 46 | dir.push("bookmarks"); 47 | info!("Looking for bookmark file {:?}", dir); 48 | dir 49 | } 50 | 51 | /// Replace an existting bookmark or add a new bookmark. 52 | /// If an entry is replaced, it will remain at the same position 53 | /// Returns the index of the existing entry or None. 54 | pub fn insert(&mut self, entry: Bookmark) -> Option { 55 | info!("Adding entry to bookmark: {:?}", entry); 56 | let index = self.entries.iter().position(|e| e.url == entry.url); 57 | if let Some(i) = index { 58 | // replace item 59 | self.entries.remove(i); 60 | self.entries.insert(i, entry); 61 | } else { 62 | // insert new item at end 63 | self.entries.push(entry); 64 | }; 65 | self.write_bookmarks_to_file() 66 | .unwrap_or_else(|err| warn!("Could not write bookmarks file: {}", err)); 67 | index 68 | } 69 | 70 | pub fn remove(&mut self, url: &Url) { 71 | info!("Removing entry to bookmark: {:?}", url); 72 | self.entries.retain(|e| &e.url != url); 73 | if let Err(why) = self.write_bookmarks_to_file() { 74 | warn!("Could not write bookmarks file: {}", why) 75 | } 76 | } 77 | 78 | pub fn get_bookmarks(&self) -> Vec { 79 | self.entries.clone() 80 | } 81 | 82 | pub fn write_bookmarks_to_file(&mut self) -> std::io::Result<()> { 83 | let path = Bookmarks::get_bookmark_path(); 84 | info!("Saving bookmarks to file: {:?}", path); 85 | 86 | let mut file = FsFile::create(&path)?; 87 | 88 | file.write_all(b"# Automatically generated by ncgopher.\n")?; 89 | for b in self.clone().entries { 90 | file.write_all(b"\n[[bookmark]]\n")?; 91 | let item = toml::to_string(&b).unwrap(); 92 | file.write_all(item.as_bytes())?; 93 | } 94 | Ok(()) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/certificates.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::collections::HashMap; 3 | use std::io::Write; 4 | use std::path::Path; 5 | use url::Url; 6 | 7 | #[derive(Clone, Debug, Serialize, Deserialize, Default)] 8 | pub struct Certificates { 9 | /// All known server certificates 10 | #[serde(rename = "certificate")] 11 | #[serde(default = "default_entries")] 12 | pub entries: HashMap, 13 | } 14 | 15 | fn default_entries() -> HashMap { 16 | HashMap::::new() 17 | } 18 | 19 | impl Certificates { 20 | pub fn new() -> Certificates { 21 | let confdir = Certificates::get_known_hosts_filename(); 22 | let mut config_string = String::new(); 23 | if Path::new(confdir.as_str()).exists() { 24 | config_string = std::fs::read_to_string(&confdir).unwrap_or_default(); 25 | } 26 | // println!("Could not read known_hosts file: {}", e); 27 | 28 | toml::from_str(&config_string).unwrap_or_default() 29 | } 30 | 31 | fn get_known_hosts_filename() -> String { 32 | let confdir: String = match dirs::config_dir() { 33 | Some(mut dir) => { 34 | dir.push(env!("CARGO_PKG_NAME")); 35 | dir.push("known_hosts"); 36 | dir.into_os_string().into_string().unwrap() 37 | } 38 | None => String::new(), 39 | }; 40 | info!("Looking for known_hosts file {}", confdir); 41 | confdir 42 | } 43 | 44 | /// Add or replace the fingerprint that would be used for the given 45 | /// normalized URL. 46 | pub fn insert(&mut self, url: &Url, fingerprint: String) { 47 | let id = Certificates::extract_domain_port(url); 48 | info!("Adding entry to known_hosts: {} = {}", id, fingerprint); 49 | 50 | self.entries.insert(id, fingerprint); 51 | if let Err(why) = self.write_to_file() { 52 | warn!("Could not write known_hosts to file: {}", why) 53 | } 54 | } 55 | 56 | /// Retrieve the fingerprint that fits the domain of the given 57 | /// normalized URL. 58 | /// 59 | /// Returns None if the URL does not have a domain or the 60 | /// host has not been visited before. 61 | pub fn get(&mut self, url: &Url) -> Option { 62 | let id = Certificates::extract_domain_port(url); 63 | info!("Looking for fingerprint for {}", id); 64 | self.entries.get(&id).cloned() 65 | } 66 | 67 | pub fn write_to_file(&mut self) -> std::io::Result<()> { 68 | let filename = Certificates::get_known_hosts_filename(); 69 | info!("Saving known_hosts to file: {}", filename); 70 | // Create a path to the desired file 71 | let path = Path::new(&filename); 72 | 73 | let mut file = std::fs::File::create(path)?; 74 | 75 | file.write_all(b"# Automatically generated by ncgopher.\n")?; 76 | file.write_all( 77 | toml::to_string(&self) 78 | .expect("known hosts could not be stored as TOML") 79 | .as_bytes(), 80 | )?; 81 | Ok(()) 82 | } 83 | 84 | /// Reduce a URL to the relevant part for fingerprinting: There may be 85 | /// different certificates for 86 | /// * different IP adresses or (sub)domains 87 | /// * different ports on the same (sub)domain 88 | fn extract_domain_port(url: &Url) -> String { 89 | let host = url.host_str().expect("gemini URL without host"); 90 | if let Some(port) = url.port() { 91 | // assumes that URL has been normalized before 92 | format!("{}:{}", host, port) 93 | } else { 94 | host.to_string() 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | ## alpha-version 4 | 5 | - [X] Implement query handler 6 | - [X] Download of binary files 7 | - [X] Refactor: Get rid of server/port/path - use url::URL 8 | - [X] Error handling for URLs 9 | - [X] Refactor: Move non-ui functions to controller 10 | - [X] Refactor: get rid of contenttype, use itemtype 11 | - [X] Implement open history entries [6/6] 12 | - [X] Open URL from history-menu 13 | - [X] Add count to history 14 | - [X] Handle duplicate history entries (not nesassary) 15 | - [X] Implement show all history dialog 16 | - [X] Write history to file 17 | - [X] Read history from file on startup 18 | 19 | - [X] Implement simple bookmark handling [2/2] 20 | Bookmark: Name, Url, Last visited, Tags? 21 | 22 | - [X] Write bookmarks to file 23 | - [X] Read bookmark from file on startup 24 | 25 | - [X] Implement settings dialog [4/5] 26 | - [X] Set homepage 27 | - [X] Default directory for downloads 28 | - [X] Search engines (maybe later?) 29 | - [X] Read config from file () 30 | - [X] Write configuration to file 31 | 32 | How to handle config-file changes? Overwrite existing config-file? 33 | Possible solution: create config-auto, and use config to extend 34 | config-auto Ignore config-filechanges while running for now 35 | 36 | - [X] [#B] Implement download of files (text/gophermap) 37 | - [X] [#C] Write README.org 38 | 39 | ## post-alpha 40 | ---------- 41 | 42 | - [X] [#A] Bugfix: Prohibit duplicate bookmark entries, open existing entry 43 | - [X] [#A] Bugfix: Reload must not add current page to history 44 | - [ ] Configurable keys 45 | - [X] Better keyboard navigation, emacs/vim key presets 46 | - [X] SPACE to page 47 | - [X] Settings dialog 48 | - [X] Setting for disabling history recording 49 | - [X] Setting for text wrap column 50 | - [ ] Tor support for gopher 51 | - [ ] Handle tags for bookmarks 52 | - [X] Search in text 53 | - [ ] Caching of gophermaps 54 | - [ ] mailcap handling 55 | - [ ] Reading list (ala Safari) 56 | - [ ] Bookmarks [0/1] 57 | - [ ] Export bookmarks to gophermap/gemini-txt/txt 58 | - [X] [#C] Themes 59 | - [X] [#C] Add tracing of UiMessage and ControllerMessage in log 60 | - [X] [#A] Bugfix: search not working 61 | - [X] TLS support 62 | - [X] Write man page 63 | - [X] Persistent history 64 | - [X] Show info about link under cursor 65 | - [X] Implement reload of page 66 | - Gemini support [8/9] 67 | - [X] Binary downloads 68 | - [X] Automatic text wrapping 69 | - [X] Handle prefomatting toggle lines 70 | - [X] Bugfix: Can\'t open WWW links from gemini 71 | - [X] Implement save as text for gemini 72 | - [X] Limit number of redirects to 5 73 | - [ ] Warning when redirecting to external server 74 | - [X] Client certificates, see [Alex\' gemini wiki](https://alexschroeder.ch/wiki/2020-07-13_Client_Certificates_and_IO%3a%3aSocket%3a%3aSSL_(Perl)) 75 | - [X] TOFU certificate pinning 76 | 77 | - [ ] Use rusttls instead of native-tls (Issue #219) 78 | - [ ] Open local file (gophermap/textfile) 79 | - [ ] Auto moka pona (rss-like?), maybe rss support 80 | - [ ] Subscribing to Gemini pages: https://gemini.circumlunar.space/docs/companion/subscription.gmi 81 | - [ ] ANSI colour rendering 82 | - [ ] Download gopherhole for offline reading 83 | - [ ] Setting for encoding 84 | - [ ] Bug: do not add non finger/gemini/gopher-url's to history. Do not add binary-download-urls to history. Do not add query item type to history 85 | - [ ] Caching 86 | 87 | - [ ] Subscribe to Atom feeds 88 | - [ ] Function for copy link to page (See e.g. https://github.com/robatipoor/cbs) 89 | - [ ] Spartan protocol support 90 | - [ ] Titan protocol support 91 | 92 | # Bugs 93 | - [ ] Reload does not work on internal about sites (or maybe it does - need to recompile to integrate changes) 94 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | extern crate backtrace; 2 | extern crate clap; 3 | #[macro_use] 4 | extern crate log; 5 | extern crate base64; 6 | extern crate dirs; 7 | extern crate idna; 8 | extern crate linkify; 9 | extern crate percent_encoding; 10 | extern crate rcgen; 11 | extern crate ring; 12 | extern crate rusqlite; 13 | extern crate serde; 14 | extern crate serde_derive; 15 | extern crate toml; 16 | extern crate x509_parser; 17 | 18 | use clap::Parser; 19 | use controller::Controller; 20 | use lazy_static::lazy_static; 21 | use settings::Settings; 22 | use std::fs::File; 23 | use std::io::{stdout, Write}; 24 | use std::sync::RwLock; 25 | use time::format_description::well_known::Rfc3339; 26 | use time::OffsetDateTime; 27 | use url::Url; 28 | 29 | mod bookmarks; 30 | mod certificates; 31 | mod clientcertificates; 32 | mod controller; 33 | mod gemini; 34 | mod gophermap; 35 | mod history; 36 | mod settings; 37 | mod ui; 38 | mod url_tools; 39 | 40 | lazy_static! { 41 | static ref SETTINGS: RwLock = RwLock::new(Settings::new()); 42 | } 43 | 44 | struct Logger { 45 | file: std::sync::RwLock, 46 | } 47 | 48 | impl Logger { 49 | fn new(file: File) -> Self { 50 | Self { 51 | file: std::sync::RwLock::new(file), 52 | } 53 | } 54 | } 55 | 56 | impl log::Log for Logger { 57 | fn enabled(&self, _: &log::Metadata) -> bool { 58 | true 59 | } 60 | fn log(&self, record: &log::Record) { 61 | let timestr = OffsetDateTime::now_local() 62 | .unwrap_or_else(|_| OffsetDateTime::now_utc()) 63 | .format(&Rfc3339) 64 | .unwrap(); 65 | self.file 66 | .write() 67 | .unwrap() 68 | .write_all(format!("{} [{:5}] {}\n", timestr, record.level(), record.args()).as_bytes()) 69 | .unwrap_or(()); 70 | } 71 | fn flush(&self) { 72 | self.file.write().unwrap().flush().unwrap_or(()); 73 | } 74 | } 75 | 76 | /// An ncurses gopher client for the modern internet 77 | #[derive(Parser, Debug)] 78 | #[clap(author, version, about, long_about = None)] 79 | struct Args { 80 | /// Enable debug logging to the specified file. If the file already exists, new content will be appended. 81 | #[clap(short, long)] 82 | debug: Option, 83 | 84 | /// Url to open after startup 85 | url: Option, 86 | } 87 | 88 | fn main() { 89 | let args = Args::parse(); 90 | 91 | let homepage = args 92 | .url 93 | .as_deref() 94 | .map(|url| Url::parse(url).unwrap_or_else(|_| panic!("Invalid URL: {}", url))) 95 | .unwrap_or_else(|| { 96 | Url::parse(SETTINGS.read().unwrap().config.homepage.as_str()) 97 | .expect("Invalid URL for configured homepage") 98 | }); 99 | if let Some(log_file) = args.debug.as_deref() { 100 | let file = std::fs::OpenOptions::new() 101 | .create(true) 102 | .append(true) 103 | .open(log_file) 104 | .expect("could not create log file"); 105 | log::set_boxed_logger(Box::new(Logger::new(file))) 106 | .unwrap_or_else(|e| panic!("could not start debug logger: {}", e)); 107 | log::set_max_level(log::LevelFilter::Trace); 108 | info!("new program run"); 109 | eprintln!("logging into file {}", log_file); 110 | } 111 | 112 | // get default hook that prints to stdout 113 | let default_hook = std::panic::take_hook(); 114 | // set new hook overwriting default hook 115 | std::panic::set_hook(Box::new(move |info| { 116 | // print to log file 117 | error!("{}\n{:?}", info, backtrace::Backtrace::new()); 118 | // run default hook to print to stdout 119 | default_hook(info); 120 | })); 121 | 122 | let mut app = cursive::default(); 123 | let theme = SETTINGS.read().unwrap().config.theme.clone(); 124 | app.load_toml(SETTINGS.read().unwrap().get_theme_by_name(theme)) 125 | .unwrap(); 126 | Controller::setup(&mut app, homepage).expect("could not create controller"); 127 | // required so async updates to the status bar get shown 128 | app.run(); 129 | print!("\x1B[?1002l"); 130 | stdout().flush().expect("could not flush stdout"); 131 | pancurses::endwin(); 132 | } 133 | -------------------------------------------------------------------------------- /src/history.rs: -------------------------------------------------------------------------------- 1 | use ::time::OffsetDateTime; 2 | use rusqlite::{params, Connection, Result}; 3 | use std::path::PathBuf; 4 | use std::rc::Rc; 5 | use url::Url; 6 | 7 | #[derive(Clone, Debug)] 8 | pub struct HistoryEntry { 9 | pub title: String, 10 | pub url: Url, 11 | pub timestamp: OffsetDateTime, 12 | pub visited_count: u16, 13 | 14 | pub position: usize, 15 | } 16 | 17 | #[derive(Clone, Debug)] 18 | pub struct History { 19 | /// Navigational stack, used for back-functionality 20 | pub stack: Vec, 21 | /// Log of all visited gopherholes 22 | sql: Rc, 23 | } 24 | 25 | impl History { 26 | pub fn new() -> Result { 27 | info!("Creating history object"); 28 | let connection = Rc::new(Connection::open(History::get_history_filename())?); 29 | connection.execute( 30 | "CREATE TABLE IF NOT EXISTS history ( 31 | id INTEGER PRIMARY KEY, 32 | title TEXT, 33 | url TEXT NOT NULL, 34 | timestmp DATETIME DEFAULT CURRENT_TIMESTAMP, 35 | visitedcount NUMBER NOT NULL DEFAULT 1 36 | )", 37 | [], 38 | )?; 39 | Ok(History { 40 | stack: Vec::new(), 41 | sql: connection, 42 | }) 43 | } 44 | 45 | fn get_history_filename() -> PathBuf { 46 | let mut dir = dirs::config_dir().expect("no configuration directory"); 47 | dir.push(env!("CARGO_PKG_NAME")); 48 | dir.push("history.db"); 49 | dir 50 | } 51 | 52 | pub fn add(&mut self, entry: HistoryEntry) -> Result<()> { 53 | info!("Adding entry to history: {:?}", entry); 54 | self.stack.push(entry.clone()); 55 | 56 | trace!("History::add(): checking for entry with url {}", entry.url); 57 | if self 58 | .sql 59 | .query_row( 60 | "SELECT id FROM history WHERE url=?1", 61 | params![&entry.url.to_string()], 62 | |_| Ok(()), 63 | ) 64 | .is_ok() 65 | { 66 | trace!("History::add(): Row exists, updating"); 67 | let mut stmt = self 68 | .sql 69 | .prepare("UPDATE history SET visitedcount=visitedcount+1,timestmp=datetime('NOW') WHERE url=?1")?; 70 | stmt.execute(params![&entry.url.to_string()])?; 71 | } else { 72 | trace!("History::add(): Adding entry"); 73 | self.sql.execute( 74 | "INSERT INTO history (url) values (?1)", 75 | [&entry.url.to_string()], 76 | )?; 77 | } 78 | Ok(()) 79 | } 80 | 81 | pub fn clear(&mut self) -> Result<()> { 82 | trace!("History::clear()"); 83 | self.stack.clear(); 84 | self.sql.execute("DELETE FROM history", [])?; 85 | Ok(()) 86 | } 87 | 88 | pub fn back(&mut self) -> Option { 89 | // Removes the topmost entry from the history and returns it 90 | if self.stack.len() > 1 { 91 | self.stack.pop(); 92 | Some(self.stack.last()?.clone()) 93 | } else { 94 | None 95 | } 96 | } 97 | 98 | pub fn update_selected_item(&mut self, index: usize) { 99 | // Updates the current selection position of the history item 100 | // on top of the stack 101 | if !self.stack.is_empty() { 102 | let mut item = self.stack.pop().expect("Could not fetch history item"); 103 | info!( 104 | "update_selected_item(): {} {} => {}", 105 | item.url, item.position, index 106 | ); 107 | item.position = index; 108 | self.stack.push(item); 109 | } 110 | } 111 | 112 | pub fn get_latest_history(&self, num_items: usize) -> Result> { 113 | let mut res = Vec::::new(); 114 | let mut stmt = self 115 | .sql 116 | .prepare( 117 | "SELECT title, url, timestmp, visitedcount FROM history ORDER BY timestmp DESC LIMIT ?1", 118 | )?; 119 | let mut rows = stmt.query(params![num_items as u32])?; 120 | while let Some(row) = rows.next()? { 121 | let title = row.get(1)?; 122 | let entry = HistoryEntry { 123 | title, 124 | url: row.get(1)?, 125 | timestamp: row.get(2)?, 126 | visited_count: row.get(3)?, 127 | position: 0, 128 | }; 129 | res.push(entry); 130 | } 131 | trace!("Returning {} history entries", res.len()); 132 | Ok(res) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/gemini.rs: -------------------------------------------------------------------------------- 1 | extern crate gemtext; 2 | use cursive::utils::lines::simple::{make_lines, LinesIterator}; 3 | use url::Url; 4 | // https://gemini.circumlunar.space/docs/spec-spec.txt 5 | 6 | #[derive(Clone, Debug, PartialEq)] 7 | pub enum GeminiType { 8 | Text, 9 | Gemini, 10 | } 11 | 12 | pub fn parse(text: &str, base_url: &Url, viewport_width: usize) -> Vec<(String, Option)> { 13 | let mut nodes = gemtext::parse(text); 14 | nodes 15 | .drain(..) 16 | .flat_map(|node: gemtext::Node| -> Vec<(String, Option)> { 17 | use gemtext::Node; 18 | 19 | // Helper function to wrap lines if necessary while indicating that they are continuations like this 20 | // ```text 21 | // ### Heading that 22 | // | goes over 23 | // \ multiple lines 24 | // ``` 25 | let continuation_lines = |first_prefix, text: &str, url: Option| { 26 | let lines = make_lines(if text.is_empty() { " " } else { text }, viewport_width); 27 | lines 28 | .iter() 29 | .enumerate() 30 | .map(|(i, row)| { 31 | let prefix = match i { 32 | 0 => first_prefix, 33 | x if x == lines.len() - 1 => "\\", 34 | _ => "|", 35 | }; 36 | 37 | ( 38 | format!("{:>5} {}", prefix, &text[row.start..row.end]), 39 | url.clone(), 40 | ) 41 | }) 42 | .collect() 43 | }; 44 | 45 | match node { 46 | Node::Text(text) => { 47 | let text = if text.is_empty() { " " } else { &text }; 48 | // Do not use continuation_lines here because text lines 49 | // should continue without special markup. 50 | LinesIterator::new(text, viewport_width) 51 | .map(|row| (format!(" {}", &text[row.start..row.end]), None)) 52 | .collect() 53 | } 54 | Node::Link { to, name } => { 55 | use crate::url_tools::human_readable_url; 56 | 57 | if let Ok(url) = base_url.join(&to) { 58 | let prefix = match url.scheme() { 59 | "https" | "http" => "[WWW]".to_string(), 60 | "gemini" => "[GEM]".to_string(), 61 | "gopher" => "[GPH]".to_string(), 62 | "mailto" => "[ \u{2709} ]".to_string(), 63 | "about" => "[ABT]".to_string(), 64 | // show first three letters of scheme, lower case to differentiate 65 | other => format!("[{}]", other.chars().take(3).collect::()), 66 | }; 67 | 68 | // transform the URL into a human redable form 69 | // escaping (by parsing as a URL) and unescaping is necessary because 70 | // the URL might have been escaped by the author 71 | let name = name.unwrap_or_else(|| human_readable_url(&url)); 72 | continuation_lines(&prefix, &name, Some(url)) 73 | } else { 74 | // broken link 75 | let mut name = name.unwrap_or_default(); 76 | name.push_str(&format!(" ?URL? {}", to)); 77 | continuation_lines("?URL?", &name, None) 78 | } 79 | } 80 | Node::Heading { level, body } => { 81 | let text = if body.is_empty() { " " } else { &body }; 82 | continuation_lines(&"#".repeat(level as usize), text, None) 83 | } 84 | Node::Quote(text) => { 85 | let text = if text.is_empty() { " " } else { &text }; 86 | // Do not use continuation_lines here because quote lines 87 | // are simply rewrapped and then handled like text. 88 | LinesIterator::new(text, viewport_width) 89 | .map(|row| (format!(" > {}", &text[row.start..row.end]), None)) 90 | .collect() 91 | } 92 | Node::ListItem(text) => continuation_lines("*", &text, None), 93 | Node::Preformatted(lines) => { 94 | // preformatted lines should not be wrapped 95 | lines 96 | .lines() 97 | .map(|line| (format!(" @ {}", line), None)) 98 | .collect() 99 | } 100 | } 101 | }) 102 | .collect::>() 103 | } 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ncgopher 2 | 3 | `ncgopher` is a gopher, gemini and finger client for the modern internet. It uses 4 | ncurses and is written in Rust. 5 | 6 | ## gopher 7 | 8 | Gopher was developed in 1991 at the University of Minnesota, and named 9 | after the school's mascot. Gopher is a menu-driven interface that 10 | allows a user to browse for text information served off of various 11 | gopher servers. 12 | 13 | ## gemini 14 | 15 | Gemini is a new application-level internet protocol for the distribution 16 | of arbitrary files, with some special consideration for serving a 17 | lightweight hypertext format which facilitates linking between files. 18 | 19 | ## Screenshot 20 | 21 | Obligatory screenshot: 22 | 23 | ![img](./screenshots/ncgopher.png "Screenshot of NcGopher") 24 | 25 | ![img](./screenshots/ncgopher-darkmode.png "Screenshot of NcGopher") 26 | 27 | ## Features 28 | 29 | - Gopher, gemini and finger support 30 | - Ncurses interface 31 | - Keyboard commands for navigation 32 | - Bookmarks support including custom title 33 | - History of visited gopher holes 34 | - Download of text files and gophermaps (Save as…) 35 | - Download of binary files 36 | - Menu for easy configuration 37 | - Mouse support in some terminals 38 | - TLS support 39 | - Darkmode! 40 | - External commands for HTML, images and Telnet 41 | - Vi-like search in text 42 | - Bookmarks, history and option to disable history recording 43 | 44 | ## Installation 45 | 46 | 47 | ### Arch Linux 48 | 49 | Arch Linux users can install ncgopher using pacman: 50 | 51 | sudo pacman -S ncgopher-git 52 | 53 | ### NixOS 54 | 55 | NixOS users can install ncgopher using nix-env: 56 | 57 | nix-env -iA nixos.ncgopher 58 | 59 | ### FreeBSD 60 | 61 | doas pkg install ncgopher 62 | 63 | ### NetBSD 64 | 65 | NetBSD users can install ncgopher using pkgin: 66 | 67 | pkgin install ncgopher 68 | 69 | ### All other systems 70 | 71 | `ncgopher` has no fancy installation process right now. There are some external 72 | dependencies which have to be installed. First and foremost you will of course 73 | need to have Rust installed. Further dependencies are the *openssl*, *ncurses* 74 | and *sqlite3* libraries. If these are not installed, the build will fail but 75 | you will most likely be able to tell what is missing. 76 | 77 | --- 78 | ### Debian-based Linux 79 | 80 | sudo apt install build-essential pkg-config libssl-dev libncurses-dev libsqlite3-dev 81 | 82 | ### Arch-based Linux 83 | 84 | sudo pacman -S base-devel pkg-config openssl ncurses sqlite 85 | 86 | ### OpenBSD 87 | 88 | doas pkg_add sqlite3 rust 89 | 90 | ### Void Linux 91 | 92 | sudo xbps-install -S base-devel ncurses-devel openssl-devel sqlite-devel 93 | 94 | ### MSYS2 (Windows, MINGW64 terminal) 95 | 96 | pacman -S base-devel mingw-w64-x86_64-pkgconf mingw-w64-x86_64-rust mingw-w64-x86_64-ncurses mingw-w64-x86_64-openssl mingw-w64-x86_64-sqlite3 97 | 98 | --- 99 | 100 | If you know how to install the listed dependencies on your operating system and it is 101 | not listed, please make a pull request to add it. 102 | 103 | After installing these dependencies run 104 | 105 | cargo install ncgopher 106 | 107 | To install the latest development version: 108 | 109 | git clone https://github.com/jansc/ncgopher.git 110 | cd ncgopher 111 | cargo build 112 | cargo run 113 | 114 | ## Key bindings 115 | 116 | During alpha, many operations are still not implemented. Key bindings can be 117 | changed by adding a `keybindings` section in your `config.toml`, if you want to 118 | change them from the defaults below: 119 | 120 | | Key | Command | 121 | | :--------- | :----------------------------- | 122 | | Arrow keys | Move around in text | 123 | | Enter | Open the link under the cursor | 124 | | Esc | Go to menubar | 125 | | Space | Scroll down one page | 126 | | g | Open new URL | 127 | | G | Edit current URL | 128 | | b | Navigate back | 129 | | q | Close application | 130 | | s | Save current page | 131 | | r | Reload current page | 132 | | i | Show link under cursor | 133 | | a | Add bookmark for current page | 134 | | l | Go to next link | 135 | | L | Go to previous link | 136 | | j | Move one line down | 137 | | k | Move one line up | 138 | | / | Search in text | 139 | | n | Move to next search result | 140 | | N | Move to previous search result | 141 | 142 | Here is an example `config.toml` with all the keybindings defined: 143 | ```toml 144 | [keybindings] 145 | open_new_url = 'o' 146 | edit_current_url = 'e' 147 | navigate_back = 'h' 148 | close = 'q' 149 | save_page = 's' 150 | reload_page = 'r' 151 | show_link = 'i' 152 | add_bookmark = 'b' 153 | next_link = 'l' 154 | previous_link = 'L' 155 | move_down = 'j' 156 | move_up = 'k' 157 | search_in_text = '/' 158 | next_search_result = 'n' 159 | previous_search_result = 'N' 160 | show_help = '?' 161 | ``` 162 | 163 | ## Mouse support 164 | 165 | `ncgopher` supports mouse interaction for menus and buttons in dialogs. 166 | If you want to select text, most terminal support selection while 167 | pressing `SHIFT`. 168 | 169 | ## Debugging 170 | 171 | The software is still in beta, and it is also my first application 172 | written in Rust. Expect lots of bugs and badly written Rust code. 173 | 174 | If the application crashes, I'd be interested in a log file. 175 | To produce one, please rerun the program with the command line flag `-d` and a 176 | file name to store the log in, for example "error.log". 177 | It should look something like this: `ncgopher -d error.log` 178 | This will append log messages to `error.log` (the file will be created if it 179 | does not exist). 180 | With this, try to reproduce the bug and take note of the backtrace output. 181 | 182 | If you know how to do that and you installed the source, you can run the 183 | program with `RUST_BACKTRACE` to get a backtrace too. 184 | 185 | ## License 186 | 187 | `ncgopher` is licensed under the BSD 2-clause license. 188 | 189 | Copyright (c) 2019-2022 The ncgopher Authors. Parts of the 190 | status bar implementation are Copyright (c) 2019, Henrik Friedrichsen 191 | -------------------------------------------------------------------------------- /src/clientcertificates.rs: -------------------------------------------------------------------------------- 1 | use ::time::Date; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::HashMap; 4 | use std::io::Write; 5 | use std::path::Path; 6 | use url::Url; 7 | 8 | #[derive(Clone, Debug, Serialize, Deserialize)] 9 | pub struct ClientCertificate { 10 | pub fingerprint: String, 11 | pub cert: String, 12 | pub private_key: String, 13 | pub common_name: String, 14 | pub expiration_date: Date, 15 | pub note: String, 16 | } 17 | 18 | #[derive(Clone, Debug, Serialize, Deserialize, Default)] 19 | pub struct ClientCertificates { 20 | /// Maps URLs to certificate fingerprints 21 | // Each certificate can be valid for several URLs 22 | #[serde(rename = "urls", default = "default_urls")] 23 | pub urls: HashMap, 24 | /// All known server certificates. Hash where the key is the 25 | /// Certificate fingerprint. 26 | #[serde(rename = "certificates", default = "default_certificates")] 27 | pub certificates: HashMap, 28 | } 29 | 30 | fn default_certificates() -> HashMap { 31 | HashMap::::new() 32 | } 33 | 34 | fn default_urls() -> HashMap { 35 | HashMap::::new() 36 | } 37 | 38 | impl ClientCertificates { 39 | pub fn new() -> ClientCertificates { 40 | let confdir = ClientCertificates::get_client_certificates_filename(); 41 | let mut config_string = String::new(); 42 | if Path::new(confdir.as_str()).exists() { 43 | config_string = std::fs::read_to_string(&confdir).unwrap_or_default(); 44 | } 45 | toml::from_str(&config_string).unwrap_or_default() 46 | } 47 | 48 | fn get_client_certificates_filename() -> String { 49 | let confdir: String = match dirs::config_dir() { 50 | Some(mut dir) => { 51 | dir.push(env!("CARGO_PKG_NAME")); 52 | dir.push("client_certificates"); 53 | dir.into_os_string().into_string().unwrap() 54 | } 55 | None => String::new(), 56 | }; 57 | info!("Looking for client_certificates file {}", confdir); 58 | confdir 59 | } 60 | 61 | /// Add or replace the fingerprint that would be used for the given 62 | /// normalized URL. 63 | pub fn insert(&mut self, client_certificate: ClientCertificate, specified_url: &Option) { 64 | let fingerprint = client_certificate.fingerprint.to_string(); 65 | self.certificates.insert( 66 | client_certificate.fingerprint.to_string(), 67 | client_certificate, 68 | ); 69 | if let Some(url) = specified_url { 70 | self.urls.insert(url.to_string(), fingerprint); 71 | } 72 | if let Err(why) = self.write_to_file() { 73 | warn!("Could not write client_certificates to file: {}", why) 74 | } 75 | } 76 | 77 | pub fn update(&mut self, cc: &ClientCertificate, urls: Vec) { 78 | let fingerprint = &cc.fingerprint; 79 | self.urls.retain(|_k, v| !&v.eq(&fingerprint)); 80 | for url in urls.iter() { 81 | self.urls.insert(url.to_string(), fingerprint.to_string()); 82 | } 83 | self.certificates 84 | .insert(fingerprint.to_string(), cc.clone()); 85 | if let Err(why) = self.write_to_file() { 86 | warn!("Could not write client_certificates to file: {}", why) 87 | } 88 | } 89 | 90 | pub fn get_client_certificate_fingerprint(&mut self, url: &Url) -> Option { 91 | if let Some(fingerprint) = self.urls.get(url.as_str()) { 92 | if self.certificates.contains_key(fingerprint) { 93 | return Some(fingerprint.to_string()); 94 | } 95 | } 96 | None 97 | } 98 | 99 | pub fn get_cert_by_fingerprint(&mut self, fingerprint: &String) -> Option { 100 | if let Some(cc) = self.certificates.get(fingerprint) { 101 | return Some(cc.cert.to_string()); 102 | } 103 | None 104 | } 105 | 106 | pub fn get_private_key_by_fingerprint(&mut self, fingerprint: &String) -> Option { 107 | if let Some(cc) = self.certificates.get(fingerprint) { 108 | return Some(cc.private_key.to_string()); 109 | } 110 | None 111 | } 112 | 113 | /// Returns a vector with all client certficiates 114 | pub fn get_client_certificates(&self) -> Vec { 115 | self.certificates.clone().into_values().collect() 116 | } 117 | 118 | /// Returns a vector with all client certficiates 119 | pub fn get_client_certificate(&self, fingerprint: &String) -> Option { 120 | if let Some(cc) = self.certificates.get(fingerprint) { 121 | return Some(cc.clone()); 122 | } 123 | None 124 | } 125 | 126 | /// Returns a list of URLs that are assigned to a given client certificate 127 | /// identified by a fingerprint 128 | pub fn get_urls_for_certificate(&self, fingerprint: &String) -> Vec { 129 | let mut map: HashMap = self.urls.clone(); 130 | map.retain(|_k, v| v.eq(&fingerprint)); 131 | map.into_keys().collect() 132 | } 133 | 134 | /// Removes a certificate and its associated URLs 135 | pub fn remove(&mut self, fingerprint: &String) { 136 | info!("Removing entry to client certificate: {:?}", fingerprint); 137 | self.urls.retain(|_k, v| !&v.eq(&fingerprint)); 138 | self.certificates 139 | .retain(|_k, v| &v.fingerprint != fingerprint); 140 | if let Err(why) = self.write_to_file() { 141 | warn!("Could not write client certificate file: {}", why) 142 | } 143 | } 144 | 145 | pub fn use_current_site(&mut self, url: &Url, fingerprint: &String) { 146 | info!("Adding {:?} to {}", url, fingerprint); 147 | self.urls.insert(url.to_string(), fingerprint.to_string()); 148 | if let Err(why) = self.write_to_file() { 149 | warn!("Could not write client certificate file: {}", why) 150 | } 151 | } 152 | 153 | /// Writes all client certificates held by this instance to a toml-file. 154 | pub fn write_to_file(&mut self) -> std::io::Result<()> { 155 | let filename = ClientCertificates::get_client_certificates_filename(); 156 | info!("Saving client_certificates to file: {}", filename); 157 | // Create a path to the desired file 158 | let path = Path::new(&filename); 159 | 160 | let mut file = std::fs::File::create(path)?; 161 | 162 | file.write_all(b"# Automatically generated by ncgopher.\n")?; 163 | file.write_all( 164 | toml::to_string(&self) 165 | .expect("known hosts could not be stored as TOML") 166 | .as_bytes(), 167 | )?; 168 | Ok(()) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/ui/layout.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use cursive::align::HAlign; 4 | use cursive::direction::Direction; 5 | use cursive::event::{AnyCb, Event, EventResult}; 6 | use cursive::theme::ColorStyle; 7 | use cursive::traits::View; 8 | use cursive::vec::Vec2; 9 | use cursive::view::{CannotFocus, IntoBoxedView, Selector}; 10 | use cursive::views::EditView; 11 | use cursive::Printer; 12 | use unicode_width::UnicodeWidthStr; 13 | 14 | struct Screen { 15 | title: String, 16 | view: Box, 17 | } 18 | 19 | pub struct Layout { 20 | views: HashMap, 21 | stack: Vec, 22 | statusbar: Box, 23 | pub search: EditView, 24 | search_focused: bool, 25 | focus: Option, 26 | screenchange: bool, 27 | last_size: Vec2, 28 | // theme: Theme, 29 | } 30 | 31 | impl Layout { 32 | pub fn new(status: T /*, theme: Theme*/) -> Layout { 33 | Layout { 34 | views: HashMap::new(), 35 | stack: Vec::new(), 36 | statusbar: status.into_boxed_view(), 37 | search: EditView::new(), 38 | search_focused: false, 39 | focus: None, 40 | screenchange: true, 41 | last_size: Vec2::new(0, 0), 42 | // theme, 43 | } 44 | } 45 | 46 | pub fn add_view, T: IntoBoxedView>(&mut self, id: S, view: T, title: S) { 47 | let s = id.into(); 48 | let screen = Screen { 49 | title: title.into(), 50 | view: view.into_boxed_view(), 51 | }; 52 | self.views.insert(s.clone(), screen); 53 | self.focus = Some(s); 54 | } 55 | 56 | pub fn view, T: IntoBoxedView>(mut self, id: S, view: T, title: S) -> Self { 57 | self.add_view(id, view, title); 58 | self 59 | } 60 | 61 | pub fn set_view>(&mut self, id: S) { 62 | let s = id.into(); 63 | self.focus = Some(s); 64 | self.screenchange = true; 65 | self.search_focused = false; 66 | self.stack.clear(); 67 | } 68 | 69 | pub fn set_title(&mut self, id: String, title: String) { 70 | if let Some(view) = self.views.get_mut(&id) { 71 | view.title = title; 72 | } 73 | } 74 | 75 | fn get_current_screen(&self) -> &Screen { 76 | if !self.stack.is_empty() { 77 | self.stack.last().unwrap() 78 | } else { 79 | let id = self.get_current_view(); 80 | self.views 81 | .get(&id) 82 | .unwrap_or_else(|| panic!("View {} missing", id)) 83 | } 84 | } 85 | 86 | pub fn get_current_view(&self) -> String { 87 | self.focus 88 | .as_ref() 89 | .cloned() 90 | .expect("Layout loaded without views") 91 | } 92 | 93 | fn get_current_screen_mut(&mut self) -> &mut Screen { 94 | if !self.stack.is_empty() { 95 | self.stack.last_mut().unwrap() 96 | } else { 97 | let id = self.get_current_view(); 98 | self.views 99 | .get_mut(&id) 100 | .unwrap_or_else(|| panic!("View {} missing", id)) 101 | } 102 | } 103 | 104 | pub fn enable_search(&mut self) { 105 | if !self.search_focused { 106 | self.search.set_content("/"); 107 | self.search_focused = true; 108 | } 109 | } 110 | 111 | pub fn clear_search(&mut self) { 112 | self.search.set_content(""); 113 | self.search_focused = false; 114 | } 115 | } 116 | 117 | impl View for Layout { 118 | fn draw(&self, printer: &Printer<'_, '_>) { 119 | let search_visible = self.search.get_content().len() > 0; 120 | let screen = self.get_current_screen(); 121 | // screen title 122 | printer.with_color(ColorStyle::title_primary(), |printer| { 123 | let offset = HAlign::Center.get_offset(screen.title.width(), printer.size.x); 124 | printer.print((offset, 0), &screen.title); 125 | 126 | if !self.stack.is_empty() { 127 | printer.print((1, 0), "<"); 128 | } 129 | }); 130 | 131 | // screen content 132 | screen.view.draw( 133 | &printer 134 | .offset((0, 1)) 135 | .cropped((printer.size.x, printer.size.y - 3)) 136 | .focused(true), 137 | ); 138 | 139 | self.statusbar 140 | .draw(&printer.offset((0, printer.size.y - 2))); 141 | 142 | if search_visible { 143 | let printer = &printer.offset((0, printer.size.y - 1)); 144 | self.search.draw(printer); 145 | } 146 | } 147 | 148 | fn layout(&mut self, size: Vec2) { 149 | self.last_size = size; 150 | 151 | self.statusbar.layout(Vec2::new(size.x, 2)); 152 | self.search.layout(Vec2::new(size.x, 1)); 153 | 154 | self.get_current_screen_mut() 155 | .view 156 | .layout(Vec2::new(size.x, size.y - 3)); 157 | 158 | // the focus view has changed, let the views know so they can redraw 159 | // their items 160 | if self.screenchange { 161 | self.screenchange = false; 162 | } 163 | } 164 | 165 | fn required_size(&mut self, constraint: Vec2) -> Vec2 { 166 | Vec2::new(constraint.x, constraint.y) 167 | } 168 | 169 | fn on_event(&mut self, event: Event) -> EventResult { 170 | let search_visible = self.search.get_content().len() > 0; 171 | if let Event::Mouse { position, .. } = event { 172 | if position.y < self.last_size.y.saturating_sub(2) { 173 | if let Some(ref id) = self.focus { 174 | let screen = self.views.get_mut(id).unwrap(); 175 | screen.view.on_event(event.relativized(Vec2::new(0, 1))); 176 | } 177 | } else if position.y < self.last_size.y { 178 | self.statusbar 179 | .on_event(event.relativized(Vec2::new(0, self.last_size.y - 2))); 180 | } 181 | 182 | EventResult::Consumed(None) 183 | } else if search_visible { 184 | self.search.on_event(event) 185 | } else { 186 | self.get_current_screen_mut().view.on_event(event) 187 | } 188 | } 189 | 190 | fn call_on_any(&mut self, s: &Selector, c: AnyCb<'_>) { 191 | if let Selector::Name("statusbar") = s { 192 | self.statusbar.call_on_any(s, c); 193 | } else { 194 | self.get_current_screen_mut().view.call_on_any(s, c) 195 | } 196 | } 197 | 198 | fn take_focus(&mut self, source: Direction) -> Result { 199 | if self.search_focused { 200 | return self.search.take_focus(source); 201 | } 202 | self.get_current_screen_mut().view.take_focus(source) 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | * NcGopher Changelog 2 | 3 | ** 0.7.0 4 | Maintenance release to fix #219 5 | Changes: 6 | - Upgraded various dependencies 7 | 8 | New features: 9 | - Support for custom keybindings 10 | 11 | ** 0.6.0 12 | Changes: 13 | - #234: Add MSYS2 MINGW64 terminal packages for Windows (pull request by Ari) 14 | - Made release-notes accessible from menu 15 | - Upgraded rusqlite to 0.30.0 16 | - Upgraded regex to 1.10.2 17 | - Upgraded serde to 1.0.193 18 | - Upgraded toml to 0.8.8 19 | - Upgraded clap to 4.4.8 20 | - Upgraded urlencoding to 2.1.3 21 | - Upgraded rcgen to 0.11.3 22 | - Upgraded unicode-width to 0.1.11 23 | - Upgraded time to 0.3.29 24 | - Upgraded base64 to 0.21.4 25 | - Upgraded url to 2.4.1 26 | - Upgraded backtrace to 0.3.69 27 | - Upgraded log to 0.4.19 28 | - Upgraded percent-encoding to 2.3.0 29 | - Upgraded idna to 0.4.0 30 | - Upgraded linkify to 0.10.0 31 | - Upgraded openssl to 0.10.55 32 | - Upgraded dirs to 5.0.1 33 | - Upgraded crossbeam-channel to 0.5.8 34 | - Upgraded mime to 0.3.17 35 | - Upgraded pem to 1.1.1 36 | - Upgraded bumpalo to 3.12.0 37 | 38 | Bugfixes: 39 | - Fixes #305 Added 'vendored' feature to native-tls 40 | - Fixes #210. Remove double dot in gopher content 41 | 42 | 43 | ** 0.5.0 44 | New features: 45 | - New shortcut: 'G' to edit current URL 46 | - Gemini TLS client certificate support 47 | - Added finger support 48 | - Setting for disabling history recording. NB. ncgopher will still save 49 | gemini certificate fingerprints and log some url info to the debug log 50 | if --debug switch is activated. Already recorded history will not be 51 | deleted. 52 | - Automatically recognize URLs in text (use Enter to open), even when 53 | displaying finger, txt files, gopher inline text or gemini text 54 | 55 | Changes: 56 | - Use ring instead of sha2 crate to calculate certificate fingerprints 57 | - Removed chrono as a dependency. Use time 0.3 instead. 58 | - Allow Gophermap entries without selector/host/port (type "i") 59 | - Use time instead of chrono crate 60 | 61 | Bugfixes: 62 | - Got rid of screen flickering on redraw 63 | 64 | ** 0.4.0 65 | New features: 66 | - Search in documents. Finally. Search with '/'. Jump to next/previous 67 | result with n/N. Fixes #5 68 | - Gemini: Check for redirect loops 69 | 70 | Changes: 71 | - Breaking: Changed key shortcuts for next/previous link to l/L 72 | - Removed Config as a dependency and use serde instead 73 | - Upgraded cursive to 0.18.0 74 | - Upgraded clap to 3.1.18 75 | - Upgraded regex to 1.5.6 76 | - Upgraded toml to 0.5.9 77 | - Upgraded serde to 1.0.137 78 | - Upgraded x509-parse to 0.13.1 79 | - Upgraded log to 0.4.17 80 | - Upgraded backtrace to 0.3.65 81 | - Upgraded crossbeam-channel to 0.5.4 82 | - Upgraded rusqlite to 0.27.0 83 | - Upgraded sha2 to 0.10.2 84 | - Upgraded pancurses to 0.17.0 85 | - Upgraded dirs to 4.0.0 86 | - Upgraded unicode-width to 0.1.9 87 | 88 | Bugfixes: 89 | - Clicking "accept the risk" in the certificate dialog will now automaically open 90 | the URL 91 | - Update status message display when fetching content 92 | - Url-decode path in gopher URIs. Fixes #78 93 | - Fixed crash when darkmode is enabled (pull request #69) 94 | - Introduced and fixed crash when bookmarks file does not exist 95 | - The open image command finally works again 96 | - Fixed name of arch package in README 97 | 98 | ** 0.3.0 99 | 100 | Changes: 101 | - parsing MIME type from gemini response instead of just checking for "text/". 102 | Other text types will now be displayed as text/plain, not text/gemini. 103 | This also allows the supposed encoding to be detected and ncgopher will now abort 104 | a request if the server signals an unsupported charset. A dialog will be displayed. 105 | 106 | Bugfixes: 107 | - Fixed bug in gopher protocol handling 108 | 109 | ** 0.2.0 110 | New features: 111 | - Setting for automatic text wrapping of gemini content 112 | - Unknown success status codes are now handled gracefully, displaying any content. 113 | - Full text/gemini support 114 | - `about` scheme and internal help pages 115 | - Current URL is displayed at the top 116 | 117 | Bugfixes: 118 | - Use download path from setting for downloading files 119 | - Fix Gemini error 59 "invalid url" from gemini://drewdevault.com, SNI is enabled 120 | - Correctly handle international domain names for Gemini 121 | - Actually update certificate fingerprints for Gemini 122 | - Correctly set the current URL when a Gemini request fails so the r key can be 123 | used for retrying 124 | 125 | Changes: 126 | - Search menu items removed in favour of internal help pages 127 | - URL scheme is now mandatory 128 | 129 | ** 0.1.5 130 | New features: 131 | - New keyborad shortcut for help: ? 132 | - Edit bookmarks 133 | - History management dialog 134 | - Certificate pinning (TOFU) for gemini 135 | - Download of gemini source (shortcut "s") 136 | 137 | Bugfixes: 138 | - Fixed opening of http/https-URLs from gemini 139 | 140 | Changes: 141 | - Search query by pressing enter 142 | - TLS is no longer optional (since required by gemini) 143 | 144 | ** 0.1.4 145 | New features: 146 | - Text wrapping 147 | - Initial Gemini support 148 | - Added more search interfaces (OpenBSD man pages, searx) 149 | - New keyboard shortcuts j and k for vim-like navigation 150 | - Added more search engines, including gemini search with GUS 151 | 152 | Bugfixes: 153 | - Reset console when quitting application 154 | - Improved error handling 155 | - Fixed gopherpedia search 156 | 157 | Changes: 158 | - Added Makefile 159 | - History is now stored in an SQLite database as the old solution 160 | performed rather bad 161 | - Added cancel buttons to search and query dialogs 162 | 163 | ** 0.1.3 [2020-04-02] 164 | 165 | New features: 166 | - Darkmode (can be set in config file or settings dialog) 167 | - External commands for telnet and html. External command for images 168 | not yet implemented 169 | - Wrapped link navigation: 'n' and 'p' move selected line to 170 | next or previous link 171 | - Implemented help menu 172 | 173 | Bugfixes: 174 | - Config file was not read on startup 175 | - Error handling for invalid gophermaps, invalid lines are ignored 176 | - Fixed bookmarking of queries 177 | - Reload must not add current page to history 178 | - Keep cursor position when navigating back 179 | 180 | Changes: 181 | - Removed unecessary code 182 | - Gophermap view now uses full width of screen 183 | 184 | ** 0.1.2 [2020-03-14] 185 | 186 | New features: 187 | - TLS support. ncgopher will automatically try to use TLS when the 188 | port number is different than 70. Fallback to a non-TLS connection 189 | - New command 'i' to show url below cursor 190 | - Implemented simple bookmarks dialog for deleting and opening bookmarks 191 | - Wrapped navigtaion. Use 'p' for go to previous url and 'n' for next 192 | url 193 | 194 | Bugfixes: 195 | - Bugfix: No more italics for all text in gophermaps 196 | - Bugfix: Rewrote URL handling, fixed crashes for certain URLs 197 | - Improved error handling when loading content 198 | 199 | Changes: 200 | - Removed some unecessary code 201 | - Updated README and man-page to reflect new keyboard shortcuts 202 | - New default homepage 203 | 204 | ** 0.1.1 [2020-03-02] 205 | 206 | First public release 207 | -------------------------------------------------------------------------------- /src/gophermap.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use url::Url; 3 | 4 | /// An menu item in a directory of Gopher resources. 5 | #[derive(Clone, Debug)] 6 | #[allow(dead_code)] 7 | pub struct GopherMapEntry { 8 | /// The type of the resource 9 | pub item_type: ItemType, 10 | /// String to display to the user. 11 | pub name: String, 12 | /// Path or identifier used for requesting this resource. 13 | pub selector: String, 14 | /// The hostname of the server hosting this resource. 15 | pub host: String, 16 | /// The TCP port of the server hosting this resource. 17 | pub port: u16, 18 | /// The combined URL of host, port and selector 19 | pub url: Url, 20 | } 21 | 22 | impl GopherMapEntry { 23 | /// Parses a raw string into a GopherMapEntry 24 | pub fn parse(line: String) -> Result { 25 | let l = line.split_terminator('\t').collect::>(); 26 | // Sometimes there are empty lines in a gophermap. 27 | // Ignore these. 28 | if l.is_empty() { 29 | return Ok(GopherMapEntry { 30 | item_type: ItemType::Inline, 31 | name: "".to_string(), 32 | selector: "/".to_string(), 33 | host: "about:blank".to_string(), 34 | port: 70, 35 | url: Url::parse("about:blank").unwrap(), 36 | }); 37 | } 38 | if l.is_empty() { 39 | // Happens e.g. if a text file is parsed as a gophermap 40 | return Err("Invalid gophermap entry (2)"); 41 | } 42 | if l[0].is_empty() { 43 | return Err("Invalid gophermap entry, no item type"); 44 | } 45 | let ch = l[0].chars().next().unwrap(); 46 | let item_type = ItemType::decode(ch); 47 | 48 | let mut name = l[0][ch.len_utf8()..].to_string(); 49 | 50 | // Remove ANSI sequences. baud.baby, I'm looking at you 51 | let ansi_sequences = Regex::new(r"(\x9B|\x1B\[)[0-?]*[ -/]*[@-~]").unwrap(); 52 | name = ansi_sequences.replace_all(name.as_str(), "").to_string(); 53 | if name.ends_with("\r") { 54 | name.pop(); 55 | } 56 | 57 | let mut url = Url::parse("gopher://example.com").unwrap(); 58 | let mut selector = String::from(""); 59 | let mut host = String::from(""); 60 | let mut port = 70; 61 | let mut path; 62 | if item_type == ItemType::Inline && l.len() == 1 { 63 | // Add support for item type inline without selector and host 64 | return Ok(GopherMapEntry { 65 | item_type, 66 | name, 67 | selector, 68 | host, 69 | port, 70 | url, 71 | }); 72 | } else { 73 | if l.len() <= 3 { 74 | // Happens e.g. if a text file is parsed as a gophermap 75 | return Err("Invalid gophermap entry (4)"); 76 | } 77 | selector = l[1].to_string(); 78 | host = l[2].to_string(); 79 | // Parse port, ignore invalid values 80 | port = l[3].parse().unwrap_or(70); 81 | path = selector.clone(); 82 | path.insert(0, ch); 83 | } 84 | 85 | if item_type == ItemType::Telnet { 86 | // Telnet URLs have no selector 87 | url.set_scheme("telnet").unwrap(); 88 | if !host.is_empty() { 89 | url.set_host(Some(host.as_str())).unwrap(); 90 | } 91 | url.set_port(Some(port)).unwrap(); 92 | } else if item_type == ItemType::Html { 93 | if path.starts_with("hURL:") { 94 | let mut html_url = path; 95 | html_url.replace_range(..5, ""); 96 | match Url::parse(html_url.as_str()) { 97 | Ok(u) => url = u, 98 | Err(e) => { 99 | warn!("Could not parse url {}: {}", e, html_url); 100 | } 101 | } 102 | } 103 | } else { 104 | if !host.is_empty() { 105 | if let Err(e) = url.set_host(Some(host.as_str())) { 106 | warn!("Could not parse host {}: {}", host.as_str(), e); 107 | return Err("Invalid host"); 108 | } 109 | } 110 | url.set_port(Some(port)).unwrap(); 111 | url.set_path(path.as_str()); 112 | } 113 | Ok(GopherMapEntry { 114 | item_type, 115 | name, 116 | selector, 117 | host, 118 | port, 119 | url, 120 | }) 121 | } 122 | 123 | pub fn label(self) -> String { 124 | self.name 125 | } 126 | } 127 | 128 | /// The type of a resource in a Gopher directory. 129 | /// 130 | /// For more details, see: https://tools.ietf.org/html/rfc1436 131 | #[derive(Debug, Eq, PartialEq, Hash, Copy, Clone)] 132 | pub enum ItemType { 133 | /// Item is a file 134 | File, 135 | /// Item is a directory 136 | Dir, 137 | /// Item is a CSO phone-book server 138 | CsoServer, 139 | /// Error 140 | Error, 141 | /// Item is a BinHexed Macintosh file. 142 | BinHex, 143 | /// Item is DOS binary archive of some sort. 144 | /// 145 | /// Client must read until the TCP connection closes. Beware. 146 | Dos, 147 | /// Item is a UNIX uuencoded file. 148 | Uuencoded, 149 | /// Item is an Index-Search server. 150 | IndexServer, 151 | /// Item points to a text-based telnet session. 152 | Telnet, 153 | /// Item is a binary file! Client must read until the TCP connection closes. Beware 154 | Binary, 155 | /// Item is a redundant server 156 | RedundantServer, 157 | /// Item points to a text-based tn3270 session. 158 | Tn3270, 159 | /// Item is a GIF format graphics file. 160 | Gif, 161 | /// Item is some kind of image file. Client decides how to display. 162 | Image, 163 | /// Item is a HTML link 164 | Html, 165 | /// Item is a document 166 | Document, 167 | /// Item is a video file 168 | Video, 169 | /// Item is MIME encoded file 170 | Mime, 171 | /// Item is a calendar file (ical?) 172 | Calendar, 173 | /// Item is a sound file 174 | Sound, 175 | /// Item is inline text or info line 176 | Inline, 177 | /// Item is a non-standard type 178 | Other(char), 179 | } 180 | 181 | impl ItemType { 182 | pub fn decode(b: char) -> Self { 183 | match b { 184 | '0' => ItemType::File, 185 | '1' => ItemType::Dir, 186 | '2' => ItemType::CsoServer, 187 | '3' => ItemType::Error, 188 | '4' => ItemType::BinHex, 189 | '5' => ItemType::Dos, 190 | '6' => ItemType::Uuencoded, 191 | '7' => ItemType::IndexServer, 192 | '8' => ItemType::Telnet, 193 | '9' => ItemType::Binary, 194 | '+' => ItemType::RedundantServer, 195 | 'T' => ItemType::Tn3270, 196 | 'g' => ItemType::Gif, 197 | 'I' => ItemType::Image, 198 | 'h' => ItemType::Html, 199 | 'd' => ItemType::Document, 200 | ';' => ItemType::Video, 201 | 'M' => ItemType::Mime, 202 | 'c' => ItemType::Calendar, 203 | 's' => ItemType::Sound, 204 | 'i' => ItemType::Inline, 205 | ch => ItemType::Other(ch), 206 | } 207 | } 208 | 209 | pub fn as_str(item_type: ItemType) -> String { 210 | match item_type { 211 | ItemType::File => "[TXT]", 212 | ItemType::Dir => "[MAP]", 213 | ItemType::CsoServer => "[CSO]", 214 | ItemType::Error => "[ERR]", 215 | ItemType::BinHex => "[BIN]", 216 | ItemType::Dos => "[DOS]", 217 | ItemType::Uuencoded => "[UU] ", 218 | ItemType::IndexServer => "[QRY]", 219 | ItemType::Telnet => "[TEL]", 220 | ItemType::Binary => "[BIN]", 221 | ItemType::RedundantServer => "[RED]", 222 | ItemType::Tn3270 => "[TRM]", 223 | ItemType::Gif => "[GIF]", 224 | ItemType::Image => "[IMG]", 225 | ItemType::Html => "[HTM]", 226 | ItemType::Document => "[DOC]", 227 | ItemType::Video => "[VID]", 228 | ItemType::Mime => "[MME]", 229 | ItemType::Calendar => "[CAL]", 230 | ItemType::Sound => "[SND]", 231 | ItemType::Inline => " ", 232 | ItemType::Other(_ch) => "[???]", 233 | } 234 | .to_string() 235 | } 236 | 237 | pub fn is_download(self) -> bool { 238 | matches!( 239 | self, 240 | ItemType::BinHex 241 | | ItemType::Dos 242 | | ItemType::Uuencoded 243 | | ItemType::Binary 244 | | ItemType::Gif 245 | | ItemType::Image 246 | | ItemType::Document 247 | | ItemType::Video 248 | | ItemType::Mime 249 | | ItemType::Calendar 250 | | ItemType::Sound 251 | ) 252 | } 253 | 254 | pub fn is_text(self) -> bool { 255 | matches!(self, ItemType::File) 256 | } 257 | 258 | pub fn is_dir(self) -> bool { 259 | matches!(self, ItemType::Dir) 260 | } 261 | 262 | pub fn is_query(self) -> bool { 263 | matches!(self, ItemType::IndexServer) 264 | } 265 | 266 | pub fn is_inline(self) -> bool { 267 | matches!(self, ItemType::Inline) 268 | } 269 | 270 | pub fn is_image(self) -> bool { 271 | matches!(self, ItemType::Gif | ItemType::Image) 272 | } 273 | 274 | pub fn is_telnet(self) -> bool { 275 | matches!(self, ItemType::Telnet) 276 | } 277 | 278 | pub fn is_html(self) -> bool { 279 | matches!(self, ItemType::Html) 280 | } 281 | 282 | /// Returns the ItemType of an url. Defaults to gophermap (ItemType::Dir 1) 283 | pub fn from_url(url: &Url) -> ItemType { 284 | let path = url.path(); 285 | let mut item_type = ItemType::Dir; 286 | let mut chars = path.chars(); 287 | if path.chars().count() > 2 && chars.next().unwrap() == '/' { 288 | item_type = ItemType::decode(chars.next().unwrap()); 289 | } 290 | item_type 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /src/settings.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Deserializer, Serialize}; 2 | use std::collections::HashMap; 3 | use std::env; 4 | use std::fs::{self, DirBuilder, File as FsFile}; 5 | use std::io::Write; 6 | use std::path::{Path, PathBuf}; 7 | use toml::Value; 8 | //use cursive::theme::{Theme, BorderStyle}; 9 | //use cursive::theme::BaseColor::*; 10 | //use cursive::theme::Color::*; 11 | //use cursive::theme::PaletteColor::*; 12 | 13 | pub struct Settings { 14 | pub config: NewConfig, 15 | config_filename: String, 16 | themes: HashMap, 17 | } 18 | 19 | fn default_open_new_url() -> char { 20 | 'g' 21 | } 22 | fn default_edit_current_url() -> char { 23 | 'G' 24 | } 25 | fn default_navigate_back() -> char { 26 | 'b' 27 | } 28 | fn default_close() -> char { 29 | 'q' 30 | } 31 | fn default_save_page() -> char { 32 | 's' 33 | } 34 | fn default_reload_page() -> char { 35 | 'r' 36 | } 37 | fn default_show_link() -> char { 38 | 'i' 39 | } 40 | fn default_add_bookmark() -> char { 41 | 'a' 42 | } 43 | fn default_next_link() -> char { 44 | 'l' 45 | } 46 | fn default_previous_link() -> char { 47 | 'L' 48 | } 49 | fn default_move_down() -> char { 50 | 'j' 51 | } 52 | fn default_move_up() -> char { 53 | 'k' 54 | } 55 | fn default_search_in_text() -> char { 56 | '/' 57 | } 58 | fn default_next_search_result() -> char { 59 | 'n' 60 | } 61 | fn default_previous_search_result() -> char { 62 | 'N' 63 | } 64 | fn default_show_help() -> char { 65 | '?' 66 | } 67 | 68 | pub fn default_keybindings() -> KeyBindings { 69 | KeyBindings { 70 | open_new_url: default_open_new_url(), 71 | edit_current_url: default_edit_current_url(), 72 | navigate_back: default_navigate_back(), 73 | close: default_close(), 74 | save_page: default_save_page(), 75 | reload_page: default_reload_page(), 76 | show_link: default_show_link(), 77 | add_bookmark: default_add_bookmark(), 78 | next_link: default_next_link(), 79 | previous_link: default_previous_link(), 80 | move_up: default_move_up(), 81 | move_down: default_move_down(), 82 | search_in_text: default_search_in_text(), 83 | next_search_result: default_next_search_result(), 84 | previous_search_result: default_previous_search_result(), 85 | show_help: default_show_help(), 86 | } 87 | } 88 | 89 | #[derive(Serialize, Deserialize, Debug, Clone)] 90 | #[serde(default = "default_keybindings")] 91 | pub struct KeyBindings { 92 | #[serde(default = "default_open_new_url", deserialize_with = "ok_or_default")] 93 | pub open_new_url: char, 94 | #[serde( 95 | default = "default_edit_current_url", 96 | deserialize_with = "ok_or_default" 97 | )] 98 | pub edit_current_url: char, 99 | #[serde(default = "default_navigate_back", deserialize_with = "ok_or_default")] 100 | pub navigate_back: char, 101 | #[serde(default = "default_close", deserialize_with = "ok_or_default")] 102 | pub close: char, 103 | #[serde(default = "default_save_page", deserialize_with = "ok_or_default")] 104 | pub save_page: char, 105 | #[serde(default = "default_reload_page", deserialize_with = "ok_or_default")] 106 | pub reload_page: char, 107 | #[serde(default = "default_show_link", deserialize_with = "ok_or_default")] 108 | pub show_link: char, 109 | #[serde(default = "default_add_bookmark", deserialize_with = "ok_or_default")] 110 | pub add_bookmark: char, 111 | #[serde(default = "default_next_link", deserialize_with = "ok_or_default")] 112 | pub next_link: char, 113 | #[serde(default = "default_previous_link", deserialize_with = "ok_or_default")] 114 | pub previous_link: char, 115 | #[serde(default = "default_move_down", deserialize_with = "ok_or_default")] 116 | pub move_down: char, 117 | #[serde(default = "default_move_up", deserialize_with = "ok_or_default")] 118 | pub move_up: char, 119 | #[serde(default = "default_search_in_text", deserialize_with = "ok_or_default")] 120 | pub search_in_text: char, 121 | #[serde( 122 | default = "default_next_search_result", 123 | deserialize_with = "ok_or_default" 124 | )] 125 | pub next_search_result: char, 126 | #[serde( 127 | default = "default_previous_search_result", 128 | deserialize_with = "ok_or_default" 129 | )] 130 | pub previous_search_result: char, 131 | #[serde(default = "default_show_help", deserialize_with = "ok_or_default")] 132 | pub show_help: char, 133 | } 134 | 135 | #[derive(Serialize, Deserialize, Debug)] 136 | pub struct NewConfig { 137 | #[serde(default = "default_download_path", deserialize_with = "ok_or_default")] 138 | pub download_path: String, 139 | #[serde(default = "default_homepage", deserialize_with = "ok_or_default")] 140 | pub homepage: String, 141 | #[serde(default = "default_debug", deserialize_with = "ok_or_default")] 142 | pub debug: String, 143 | #[serde(default = "default_theme", deserialize_with = "ok_or_default")] 144 | pub theme: String, 145 | #[serde(default = "default_html_command", deserialize_with = "ok_or_default")] 146 | pub html_command: String, 147 | #[serde(default = "default_image_command", deserialize_with = "ok_or_default")] 148 | pub image_command: String, 149 | #[serde(default = "default_telnet_command", deserialize_with = "ok_or_default")] 150 | pub telnet_command: String, 151 | #[serde(default = "default_textwrap", deserialize_with = "ok_or_default")] 152 | pub textwrap: String, 153 | #[serde( 154 | default = "default_disable_history", 155 | deserialize_with = "ok_or_default" 156 | )] 157 | pub disable_history: bool, 158 | #[serde( 159 | default = "default_disable_identities", 160 | deserialize_with = "ok_or_default" 161 | )] 162 | pub disable_identities: bool, 163 | 164 | // Option<> supports older config files that don't have this. 165 | pub keybindings: Option, 166 | } 167 | 168 | fn ok_or_default<'a, T, D>(deserializer: D) -> Result 169 | where 170 | T: Deserialize<'a> + Default, 171 | D: Deserializer<'a>, 172 | { 173 | let v: Value = Deserialize::deserialize(deserializer)?; 174 | Ok(T::deserialize(v).unwrap_or_default()) 175 | } 176 | 177 | fn default_download_path() -> String { 178 | // Try to determine a sensible default download dir and create it if need be. 179 | let dl_dir = if let Ok(home) = env::var("HOME") { 180 | Some([&home, "Downloads"].iter().collect::()) 181 | } else if let Ok(tmp) = env::var("TMP") { 182 | Some(PathBuf::from(tmp)) 183 | } else if let Ok(cwd) = env::current_exe() { 184 | Some(cwd) 185 | } else { 186 | None 187 | }; 188 | 189 | if let Some(dl_dir) = dl_dir { 190 | DirBuilder::new().recursive(true).create(&dl_dir).ok(); // Continue on failure. 191 | return dl_dir.into_os_string().into_string().unwrap_or_default(); 192 | } 193 | String::new() 194 | } 195 | 196 | fn default_homepage() -> String { 197 | "about:help".to_owned() 198 | } 199 | fn default_debug() -> String { 200 | "false".to_owned() 201 | } 202 | fn default_theme() -> String { 203 | "lightmode".to_owned() 204 | } 205 | fn default_html_command() -> String { 206 | "".to_owned() 207 | } 208 | fn default_image_command() -> String { 209 | "".to_owned() 210 | } 211 | fn default_telnet_command() -> String { 212 | "".to_owned() 213 | } 214 | fn default_textwrap() -> String { 215 | "80".to_owned() 216 | } 217 | fn default_disable_history() -> bool { 218 | false 219 | } 220 | fn default_disable_identities() -> bool { 221 | false 222 | } 223 | 224 | impl Settings { 225 | pub fn new() -> Settings { 226 | // Create config dir if necessary 227 | match dirs::config_dir() { 228 | Some(mut dir) => { 229 | dir.push(env!("CARGO_PKG_NAME")); 230 | let dir = dir.into_os_string().into_string().unwrap(); 231 | if !Path::new(&dir).exists() { 232 | if let Err(why) = fs::create_dir_all(dir) { 233 | warn!("Could not create config dir: {}", why) 234 | } 235 | } 236 | } 237 | None => { 238 | println!("Could not determine config dir"); 239 | } 240 | }; 241 | 242 | let confdir = match dirs::config_dir() { 243 | Some(mut dir) => { 244 | dir.push(env!("CARGO_PKG_NAME")); 245 | dir.push("config.toml"); 246 | dir.into_os_string().into_string().unwrap() 247 | } 248 | None => String::new(), 249 | }; 250 | let config_filename = confdir.clone(); 251 | println!("Looking for config file {}", confdir); 252 | 253 | let mut themes = HashMap::new(); 254 | themes.insert( 255 | "darkmode".to_string(), 256 | include_str!("themes/darkmode.toml").to_string(), 257 | ); 258 | themes.insert( 259 | "lightmode".to_string(), 260 | include_str!("themes/lightmode.toml").to_string(), 261 | ); 262 | 263 | let mut config_string = String::new(); 264 | if Path::new(confdir.as_str()).exists() { 265 | config_string = std::fs::read_to_string(confdir).unwrap(); 266 | } 267 | let config_table: NewConfig = toml::from_str(&config_string).unwrap(); 268 | 269 | Settings { 270 | config: config_table, 271 | config_filename, 272 | themes, 273 | } 274 | } 275 | 276 | pub fn write_settings_to_file(&mut self) -> std::io::Result<()> { 277 | let filename = self.config_filename.clone(); 278 | info!("Saving settings to file: {}", filename); 279 | // Create a path to the desired file 280 | let path = Path::new(&filename); 281 | let mut file = FsFile::create(path)?; 282 | 283 | file.write_all(b"# Automatically generated by ncgopher.\n")?; 284 | 285 | let toml = toml::to_string(&self.config).unwrap(); 286 | file.write_all(toml.as_bytes()) 287 | } 288 | 289 | /* 290 | pub fn set(&mut self, key: &str, value: T) -> Result<&mut Config, ConfigError> 291 | where 292 | T: Into, 293 | { 294 | self.config.set::(key, value) 295 | } 296 | 297 | pub fn get_str(&self, key: &str) -> Result { 298 | self.config.get_string(key) 299 | } 300 | */ 301 | 302 | /* 303 | // Get custom theme. TODO: Read from config file 304 | pub fn get_theme(&self) -> Theme { 305 | let mut theme = Theme::default(); 306 | theme.shadow = true; 307 | theme.borders = BorderStyle::Simple; 308 | theme.palette[Background] = Dark(Blue); 309 | theme.palette[View] = Light(Black); 310 | theme.palette[Primary] = Dark(Blue); 311 | theme.palette[Highlight] = Light(Cyan); 312 | theme.palette[HighlightInactive] = Dark(Cyan); 313 | theme.palette[TitlePrimary] = Dark(Magenta); 314 | /* 315 | Black, 316 | Red, 317 | Green, 318 | Yellow, 319 | Blue, 320 | Magenta, 321 | Cyan, 322 | White, 323 | 324 | Background, 325 | Shadow, 326 | View, 327 | Primary, 328 | Secondary, 329 | Tertiary, 330 | TitlePrimary, 331 | TitleSecondary, 332 | Highlight, 333 | HighlightInactive, 334 | */ 335 | theme 336 | } 337 | */ 338 | 339 | pub fn get_theme_by_name(&self, name: String) -> &str { 340 | self.themes[&name].as_str() 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /src/ui/setup.rs: -------------------------------------------------------------------------------- 1 | use crate::bookmarks::Bookmark; 2 | use crate::controller::{Controller, Direction}; 3 | use crate::gophermap::{GopherMapEntry, ItemType}; 4 | use crate::history::HistoryEntry; 5 | use crate::settings::default_keybindings; 6 | use crate::ui::{dialogs, layout::Layout, statusbar::StatusBar}; 7 | use crate::SETTINGS; 8 | use cursive::{ 9 | event::Key, 10 | menu::Tree, 11 | view::{Nameable, Resizable, Scrollable}, 12 | views::{ 13 | Dialog, NamedView, OnEventView, ResizedView, ScrollView, SelectView, TextView, ViewRef, 14 | }, 15 | Cursive, View, 16 | }; 17 | use url::Url; 18 | 19 | fn render_help_text() -> String { 20 | let keybindings = SETTINGS 21 | .read() 22 | .expect("Could not get keybindings!") 23 | .config 24 | .keybindings 25 | .clone() 26 | .unwrap_or(default_keybindings()); 27 | 28 | format!( 29 | r#" 30 | |------------+--------------------------------| 31 | | Key | Command | 32 | |------------+--------------------------------| 33 | | Arrow keys | Move around in text | 34 | | Enter | Open the link under the cursor | 35 | | Esc | Go to menubar | 36 | | Space | Scroll down one page | 37 | | {} | Open new URL | 38 | | {} | Edit current URL | 39 | | {} | Navigate back | 40 | | {} | Close application | 41 | | {} | Save current page | 42 | | {} | Reload current page | 43 | | {} | Show link under cursor | 44 | | {} | Add bookmark for current page | 45 | | {} | Go to next link | 46 | | {} | Go to previous link | 47 | | {} | Move one line down | 48 | | {} | Move one line up | 49 | | {} | Search in text | 50 | | {} | Move to next search result | 51 | | {} | Move to previous search result | 52 | | {} | Display this help text | 53 | |------------+--------------------------------|"#, 54 | keybindings.open_new_url, 55 | keybindings.edit_current_url, 56 | keybindings.navigate_back, 57 | keybindings.close, 58 | keybindings.save_page, 59 | keybindings.reload_page, 60 | keybindings.show_link, 61 | keybindings.add_bookmark, 62 | keybindings.next_link, 63 | keybindings.previous_link, 64 | keybindings.move_down, 65 | keybindings.move_up, 66 | keybindings.search_in_text, 67 | keybindings.next_search_result, 68 | keybindings.previous_search_result, 69 | keybindings.show_help, 70 | ) 71 | } 72 | 73 | pub fn setup(app: &mut Cursive) { 74 | trace!("ui::setup"); 75 | setup_keys(app); 76 | setup_menu(app); 77 | setup_ui(app); 78 | } 79 | 80 | /// Register global keys. 81 | fn setup_keys(app: &mut Cursive) { 82 | app.set_autohide_menu(false); 83 | 84 | let keybindings = SETTINGS 85 | .read() 86 | .expect("Could not get keybindings!") 87 | .config 88 | .keybindings 89 | .clone() 90 | .unwrap_or(default_keybindings()); 91 | 92 | app.add_global_callback(Key::Esc, |app| { 93 | app.call_on_name("main", |v: &mut Layout| v.clear_search()) 94 | .expect("main layout missing"); 95 | app.select_menubar() 96 | }); 97 | app.add_global_callback(keybindings.close, Cursive::quit); 98 | app.add_global_callback(keybindings.open_new_url, dialogs::open_url); 99 | app.add_global_callback(keybindings.edit_current_url, dialogs::open_current_url); 100 | 101 | app.add_global_callback(keybindings.navigate_back, |app| { 102 | // step back history 103 | app.user_data::() 104 | .expect("controller missing") 105 | .navigate_back(); 106 | }); 107 | app.add_global_callback(keybindings.reload_page, |app| { 108 | // reload the current page 109 | let index = Controller::get_selected_item_index(app); 110 | let controller = app.user_data::().expect("controller missing"); 111 | let current_url = controller.current_url.lock().unwrap().clone(); 112 | controller.open_url(current_url, false, index); 113 | }); 114 | app.add_global_callback(keybindings.save_page, dialogs::save_as); 115 | app.add_global_callback(keybindings.show_link, |app| { 116 | // show info about currently selected line 117 | let current_view = app 118 | .call_on_name("main", |v: &mut Layout| v.get_current_view()) 119 | .expect("main layout missing"); 120 | 121 | match current_view.as_str() { 122 | "content" => { 123 | let view: ViewRef> = 124 | app.find_name("content").expect("View content missing"); 125 | let cur = view.selected_id().unwrap_or(0); 126 | if let Some((_, item)) = view.get_item(cur) { 127 | match item.item_type { 128 | ItemType::Html => { 129 | let mut url = item.url.to_string(); 130 | if url.starts_with("URL:") { 131 | url.replace_range(..3, ""); 132 | } 133 | app.user_data::() 134 | .expect("controller missing") 135 | .set_message(&format!("URL '{}'", url)); 136 | } 137 | ItemType::Inline => (), 138 | _ => app 139 | .user_data::() 140 | .expect("controller missing") 141 | .set_message(&format!("URL '{}'", item.url)), 142 | } 143 | }; 144 | } 145 | "gemini_content" => { 146 | let view: ViewRef>> = app 147 | .find_name("gemini_content") 148 | .expect("View gemini missing"); 149 | let cur = view.selected_id().unwrap_or(0); 150 | if let Some((_, Some(url))) = view.get_item(cur) { 151 | app.user_data::() 152 | .expect("controller missing") 153 | .set_message(&format!("URL '{}'", url)); 154 | } 155 | } 156 | other => unreachable!("unknown view {} in main layout", other), 157 | } 158 | }); 159 | app.add_global_callback(keybindings.move_down, |app| { 160 | // go to next line 161 | move_selection(app, Direction::Next); 162 | }); 163 | app.add_global_callback(keybindings.move_up, |app| { 164 | // go to previous line 165 | move_selection(app, Direction::Previous); 166 | }); 167 | app.add_global_callback(keybindings.next_link /*Key::Tab*/, |app| { 168 | // go to next link 169 | move_to_link(app, Direction::Next); 170 | }); 171 | app.add_global_callback( 172 | keybindings.previous_link, /*Event::Shift(Key::Tab)*/ 173 | |app| { 174 | // go to previous link 175 | move_to_link(app, Direction::Previous); 176 | }, 177 | ); 178 | app.add_global_callback(keybindings.add_bookmark, dialogs::add_bookmark_current_url); 179 | app.add_global_callback(keybindings.show_help, |s| { 180 | s.add_layer( 181 | Dialog::around(TextView::new(render_help_text().as_str()).scrollable()) 182 | .dismiss_button("Ok"), 183 | ); 184 | }); 185 | app.add_global_callback(keybindings.search_in_text, move |app| { 186 | app.call_on_name("main", |v: &mut Layout| v.enable_search()) 187 | .expect("main layout missing"); 188 | }); 189 | app.add_global_callback(keybindings.next_search_result, |app| { 190 | let controller = app.user_data::().expect("controller missing"); 191 | let hits = controller.current_search_results.clone(); 192 | if let Some(content) = app.find_name::>("content") { 193 | let scroll_view = app 194 | .find_name::>>>>( 195 | "content_scroll", 196 | ) 197 | .expect("gopher scroll view missing"); 198 | move_to_next_item(content, scroll_view, Direction::Next, hits); 199 | } else if let Some(content) = app.find_name::>>("gemini_content") { 200 | let scroll_view = app 201 | .find_name::>>>>>( 202 | "gemini_content_scroll", 203 | ) 204 | .expect("gemini scroll view missing"); 205 | move_to_next_item(content, scroll_view, Direction::Next, hits); 206 | } else { 207 | unreachable!("view content and gemini_content missing"); 208 | } 209 | }); 210 | app.add_global_callback(keybindings.previous_search_result, |app| { 211 | let controller = app.user_data::().expect("controller missing"); 212 | let hits = controller.current_search_results.clone(); 213 | if let Some(content) = app.find_name::>("content") { 214 | let scroll_view = app 215 | .find_name::>>>>( 216 | "content_scroll", 217 | ) 218 | .expect("gopher scroll view missing"); 219 | move_to_next_item(content, scroll_view, Direction::Previous, hits); 220 | } else if let Some(content) = app.find_name::>>("gemini_content") { 221 | let scroll_view = app 222 | .find_name::>>>>>( 223 | "gemini_content_scroll", 224 | ) 225 | .expect("gemini scroll view missing"); 226 | move_to_next_item(content, scroll_view, Direction::Previous, hits); 227 | } else { 228 | unreachable!("view content and gemini_content missing"); 229 | } 230 | }); 231 | } 232 | 233 | fn setup_menu(app: &mut Cursive) { 234 | let menubar = app.menubar(); 235 | menubar.add_subtree( 236 | "File", 237 | Tree::new() 238 | .leaf("Open URL...", dialogs::open_url) 239 | .delimiter() 240 | .leaf("Save page as...", dialogs::save_as) 241 | .leaf("Settings...", dialogs::settings) 242 | .delimiter() 243 | .leaf("Quit", Cursive::quit), 244 | ); 245 | menubar.add_subtree( 246 | "History", 247 | Tree::new() 248 | .leaf("Show all history...", dialogs::edit_history) 249 | .leaf("Clear history", |app| { 250 | app.user_data::() 251 | .expect("controller missing") 252 | .clear_history(); 253 | }) 254 | .delimiter(), 255 | ); 256 | menubar.add_subtree( 257 | "Bookmarks", 258 | Tree::new() 259 | .leaf("Edit...", dialogs::edit_bookmarks) 260 | .leaf("Add bookmark", dialogs::add_bookmark_current_url) 261 | .delimiter(), 262 | ); 263 | menubar.add_subtree( 264 | "Identities", 265 | Tree::new() 266 | .leaf("New identity...", |app| { 267 | dialogs::add_client_certificate(app, None); 268 | }) 269 | .leaf("Manage identities...", dialogs::manage_client_certificates), 270 | ); 271 | menubar.add_subtree( 272 | "Help", 273 | Tree::new() 274 | .subtree( 275 | "Help", 276 | Tree::new() 277 | .leaf("Keys", |s| { 278 | s.add_layer( 279 | Dialog::around(TextView::new(render_help_text().as_str()).scrollable()) 280 | .dismiss_button("Ok"), 281 | ) 282 | }) 283 | .leaf("Extended", |app| { 284 | app.user_data::() 285 | .expect("controller missing") 286 | .open_url(Url::parse("about:help").unwrap(), false, 0); 287 | }) 288 | .leaf("Release notes", |app| { 289 | app.user_data::() 290 | .expect("controller missing") 291 | .open_url(Url::parse("about:release-notes").unwrap(), false, 0); 292 | }), 293 | ) 294 | .leaf("About", |s| { 295 | s.add_layer(Dialog::info(format!( 296 | " ncgopher v{:<15}\n\ 297 | \u{20} A Gopher and Gemini client for the modern internet\n\ 298 | \u{20} (c) 2019-2022 The ncgopher Authors\n\ 299 | \u{20}\n\ 300 | \u{20} Originally developed by Jan Schreiber \n\ 301 | \u{20} gopher://jan.bio\n\ 302 | \u{20} gemini://jan.bio", 303 | env!("CARGO_PKG_VERSION") 304 | ))) 305 | }), 306 | ); 307 | } 308 | 309 | /// Set up the user interface 310 | fn setup_ui(app: &mut Cursive) { 311 | info!("setup_ui"); 312 | 313 | // Create gophermap content view 314 | let view: SelectView = SelectView::new(); 315 | let scrollable = view 316 | .with_name("content") 317 | .full_width() 318 | .scrollable() 319 | .with_name("content_scroll"); 320 | let event_view = OnEventView::new(scrollable).on_event(' ', |app| { 321 | app.call_on_name( 322 | "content_scroll", 323 | |s: &mut ScrollView>>>| { 324 | let rect = s.content_viewport(); 325 | let bl = rect.bottom_left(); 326 | s.set_offset(bl); 327 | }, 328 | ); 329 | }); 330 | 331 | // Create gemini content view 332 | let view: SelectView> = SelectView::new(); 333 | let scrollable = view 334 | .with_name("gemini_content") 335 | .full_width() 336 | .scrollable() 337 | .with_name("gemini_content_scroll"); 338 | let gemini_event_view = OnEventView::new(scrollable).on_event(' ', |app| { 339 | app.call_on_name( 340 | "gemini_content_scroll", 341 | |s: &mut ScrollView>>>>| { 342 | let rect = s.content_viewport(); 343 | let bl = rect.bottom_left(); 344 | s.set_offset(bl); 345 | }, 346 | ); 347 | }); 348 | let status = StatusBar::new().with_name("statusbar"); 349 | let mut layout = Layout::new(status /*, theme*/) 350 | .view("content", event_view, "Gophermap") 351 | .view("gemini_content", gemini_event_view, "Gemini"); 352 | layout.set_view("content"); 353 | app.add_fullscreen_layer(layout.with_name("main")); 354 | 355 | app.call_on_name("main", |v: &mut Layout| { 356 | v.search.set_on_edit(move |app, cmd, _| { 357 | app.call_on_name("main", |v: &mut Layout| { 358 | if cmd.is_empty() { 359 | v.clear_search(); 360 | } 361 | }); 362 | }); 363 | v.search.set_on_submit(move |app, search_str| { 364 | app.call_on_name("main", |v: &mut Layout| { 365 | v.clear_search(); 366 | }); 367 | app.user_data::() 368 | .expect("controller missing") 369 | .search(search_str[1..].to_string()); 370 | }); 371 | }) 372 | .expect("main layout missing"); 373 | } 374 | 375 | pub fn setup_bookmark_menu(app: &mut Cursive, bookmarks: &Vec) { 376 | // Add bookmarks to bookmark menu on startup 377 | info!("Adding existing bookmarks to menu"); 378 | let menutree = app 379 | .menubar() 380 | .find_subtree("Bookmarks") 381 | .expect("bookmarks menu missing"); 382 | for entry in bookmarks { 383 | let url = entry.url.clone(); 384 | menutree.insert_leaf(3, &entry.title, move |app| { 385 | app.user_data::() 386 | .expect("controller missing") 387 | .open_url(url.clone(), true, 0); 388 | }); 389 | } 390 | } 391 | 392 | pub fn setup_history_menu(app: &mut Cursive, entries: &Vec) { 393 | // Add old entries to history on start-up 394 | let menutree = app 395 | .menubar() 396 | .find_subtree("History") 397 | .expect("history menu missing"); 398 | for entry in entries { 399 | let title = entry.title.clone(); 400 | let url = entry.url.clone(); 401 | menutree.insert_leaf(3, &title, move |app| { 402 | app.user_data::() 403 | .expect("controller missing") 404 | .open_url(url.clone(), true, 0); 405 | }); 406 | } 407 | } 408 | 409 | //--------- interface manipulation functions --------------------------- 410 | 411 | fn move_selection(app: &mut Cursive, dir: Direction) { 412 | let current_view = app 413 | .find_name::("main") 414 | .expect("main layout missing") 415 | .get_current_view(); 416 | 417 | match current_view.as_str() { 418 | "content" => { 419 | let mut view = app 420 | .find_name::>("content") 421 | .expect("View content missing"); 422 | let callback = match dir { 423 | Direction::Next => view.select_down(1), 424 | Direction::Previous => view.select_up(1), 425 | }; 426 | callback(app); 427 | if let Some(id) = view.selected_id() { 428 | app.find_name::>>>>( 429 | "content_scroll", 430 | ) 431 | .expect("gopher scroll view missing") 432 | .set_offset(cursive::Vec2::new(0, id)); 433 | } 434 | } 435 | "gemini_content" => { 436 | let mut view = app 437 | .find_name::>>("gemini_content") 438 | .expect("View gemini_content missing"); 439 | let callback = match dir { 440 | Direction::Next => view.select_down(1), 441 | Direction::Previous => view.select_up(1), 442 | }; 443 | callback(app); 444 | if let Some(id) = view.selected_id() { 445 | app.find_name::>>>>>( 446 | "gemini_content_scroll", 447 | ) 448 | .expect("gemini scroll view missing") 449 | .set_offset(cursive::Vec2::new(0, id)); 450 | } 451 | } 452 | other => unreachable!("unknown view {} in main layout", other), 453 | } 454 | } 455 | 456 | fn move_to_link(app: &mut Cursive, dir: Direction) { 457 | let current_view = app 458 | .find_name::("main") 459 | .expect("main layout missing") 460 | .get_current_view(); 461 | match current_view.as_str() { 462 | "content" => move_to_link_gopher(app, dir), 463 | "gemini_content" => move_to_link_gemini(app, dir), 464 | view => unreachable!("unknown view {} in main layout", view), 465 | } 466 | } 467 | 468 | fn move_to_link_gemini(app: &mut Cursive, dir: Direction) { 469 | let mut view = app 470 | .find_name::>>("gemini_content") 471 | .expect("view gemini_content missing"); 472 | let cur = view.selected_id().unwrap_or(0); 473 | let mut i = cur; 474 | match dir { 475 | Direction::Next => { 476 | i += 1; // Start at the element after the current row 477 | loop { 478 | if i >= view.len() { 479 | i = 0; // Wrap and start from scratch 480 | continue; 481 | } 482 | let (_, item) = view.get_item(i).unwrap(); 483 | if i == cur { 484 | break; // Once we reach the current item, we quit 485 | } 486 | if item.is_some() { 487 | break; 488 | } 489 | i += 1; 490 | } 491 | } 492 | Direction::Previous => { 493 | if i > 0 { 494 | i -= 1; // Start at the element before the current row 495 | } else { 496 | i = view.len() - 1; 497 | } 498 | loop { 499 | if i == 0 { 500 | i = view.len() - 1; // Wrap and start from the end 501 | continue; 502 | } 503 | let (_, item) = view.get_item(i).unwrap(); 504 | if i == cur { 505 | break; // Once we reach the current item, we quit 506 | } 507 | if item.is_some() { 508 | break; 509 | } 510 | i -= 1; 511 | } 512 | } 513 | } 514 | view.take_focus(cursive::direction::Direction::front()).ok(); 515 | view.set_selection(i); 516 | 517 | // Scroll to selected row 518 | let selected_id = view.selected_id().unwrap(); 519 | app.find_name::>>>>>( 520 | "gemini_content_scroll", 521 | ) 522 | .expect("gemini scroll view missing") 523 | .set_offset(cursive::Vec2::new(0, selected_id)); 524 | } 525 | 526 | fn move_to_link_gopher(app: &mut Cursive, dir: Direction) { 527 | let mut view = app 528 | .find_name::>("content") 529 | .expect("View content missing"); 530 | let cur = view.selected_id().unwrap_or(0); 531 | let mut i = cur; 532 | match dir { 533 | Direction::Next => { 534 | i += 1; // Start at the element after the current row 535 | loop { 536 | if i >= view.len() { 537 | i = 0; // Wrap and start from scratch 538 | continue; 539 | } 540 | let (_, item) = view.get_item(i).unwrap(); 541 | if i == cur { 542 | break; // Once we reach the current item, we quit 543 | } 544 | if !item.item_type.is_inline() { 545 | break; 546 | } 547 | i += 1; 548 | } 549 | } 550 | Direction::Previous => { 551 | if i > 0 { 552 | i -= 1; // Start at the element before the current row 553 | } else { 554 | i = view.len() - 1; 555 | } 556 | loop { 557 | if i == 0 { 558 | i = view.len() - 1; // Wrap and start from the end 559 | continue; 560 | } 561 | let (_, item) = view.get_item(i).unwrap(); 562 | if i == cur { 563 | break; // Once we reach the current item, we quit 564 | } 565 | if !item.item_type.is_inline() { 566 | break; 567 | } 568 | i -= 1; 569 | } 570 | } 571 | } 572 | view.take_focus(cursive::direction::Direction::front()).ok(); 573 | view.set_selection(i); 574 | 575 | // Scroll to selected row 576 | let selected_id = view.selected_id().unwrap(); 577 | app.find_name::>>>>( 578 | "content_scroll", 579 | ) 580 | .expect("gopher scroll view missing") 581 | .set_offset(cursive::Vec2::new(0, selected_id)); 582 | } 583 | 584 | /// Moves the current selection to the next/previous item in the given vector of indices 585 | pub(crate) fn move_to_next_item( 586 | mut view: ViewRef>, 587 | mut scroll_view: ViewRef>>>>, 588 | dir: Direction, 589 | hits: Vec, 590 | ) -> usize 591 | where 592 | T: Send, 593 | T: Sync, 594 | { 595 | if hits.is_empty() { 596 | // Not hits - don't move 597 | return 0; 598 | } 599 | let cur = view.selected_id().unwrap_or(0); 600 | let newpos = match dir { 601 | Direction::Next => { 602 | let first = hits.clone().into_iter().next().unwrap(); 603 | match hits.into_iter().find(|&x| x > cur) { 604 | Some(x) => x, 605 | None => first, // wrap search 606 | } 607 | } 608 | Direction::Previous => { 609 | let last = hits.clone().into_iter().nth(hits.len() - 1).unwrap(); 610 | match hits.into_iter().rev().find(|&x| x < cur) { 611 | Some(x) => x, 612 | None => last, // wrap search 613 | } 614 | } 615 | }; 616 | view.take_focus(cursive::direction::Direction::front()).ok(); 617 | view.set_selection(newpos); 618 | scroll_view.set_offset(cursive::Vec2::new(0, newpos)); 619 | newpos 620 | } 621 | -------------------------------------------------------------------------------- /src/ui/dialogs.rs: -------------------------------------------------------------------------------- 1 | use crate::bookmarks::Bookmark; 2 | use crate::clientcertificates::ClientCertificate; 3 | use crate::history::HistoryEntry; 4 | use crate::url_tools::download_filename_from_url; 5 | use crate::{Controller, SETTINGS}; 6 | use cursive::{ 7 | view::{Nameable, Resizable, Scrollable}, 8 | views::{ 9 | Button, Checkbox, Dialog, DummyView, EditView, LinearLayout, RadioButton, RadioGroup, 10 | SelectView, TextArea, TextView, 11 | }, 12 | Cursive, 13 | }; 14 | use std::time::SystemTime; 15 | use std::vec::Vec; 16 | use time::{format_description, Date, OffsetDateTime}; 17 | use url::{Position, Url}; 18 | 19 | pub(super) fn add_bookmark_current_url(app: &mut Cursive) { 20 | let controller = app.user_data::().expect("controller missing"); 21 | let current_url = controller.current_url.lock().unwrap().clone(); 22 | add_bookmark(app, current_url); 23 | } 24 | 25 | pub(crate) fn add_bookmark(app: &mut Cursive, url: Url) { 26 | edit_bookmark(app, url, "", ""); 27 | } 28 | 29 | pub fn edit_bookmark(app: &mut Cursive, url: Url, title: &str, tags: &str) { 30 | app.add_layer( 31 | Dialog::new() 32 | .title("Add Bookmark") 33 | .content( 34 | LinearLayout::vertical() 35 | .child(TextView::new("URL:")) 36 | .child( 37 | EditView::new() 38 | .content(url.as_str()) 39 | .with_name("url") 40 | .fixed_width(30), 41 | ) 42 | .child(TextView::new("\nTitle:")) 43 | .child( 44 | EditView::new() 45 | .content(title) 46 | .with_name("title") 47 | .fixed_width(30), 48 | ) 49 | .child(TextView::new("Tags (comma separated):")) 50 | .child( 51 | EditView::new() 52 | .content(tags) 53 | .with_name("tags") 54 | .fixed_width(30), 55 | ), 56 | ) 57 | .button("Ok", |app| { 58 | let url = app.find_name::("url").unwrap().get_content(); 59 | let title = app.find_name::("title").unwrap().get_content(); 60 | let tags = app.find_name::("tags").unwrap().get_content(); 61 | 62 | // Validate URL 63 | if let Ok(url) = Url::parse(&url) { 64 | // close edit bookmark 65 | app.pop_layer(); 66 | app.user_data::() 67 | .expect("controller missing") 68 | .add_bookmark_action(url, (*title).clone(), (*tags).clone()); 69 | } else { 70 | // do not close the dialog so the user can make 71 | // corrections 72 | app.add_layer(Dialog::info("Invalid URL!")); 73 | } 74 | }) 75 | .button("Cancel", |app| { 76 | app.pop_layer(); // Close edit bookmark 77 | }), 78 | ); 79 | } 80 | 81 | pub(crate) fn certificate_changed(app: &mut Cursive, url: Url, fingerprint: String) { 82 | app.add_layer( 83 | Dialog::new() 84 | .title("Certificate warning") 85 | .content(TextView::new(format!("The certificate for the following domain has changed:\n{}\nDo you want to continue?", url.host_str().unwrap()))) 86 | .button("Cancel", |app| { 87 | app.pop_layer(); // Close dialog 88 | }) 89 | .button("Accept the risk", move |app| { 90 | app.pop_layer(); // Close dialog 91 | Controller::certificate_changed_action(app, &url, fingerprint.clone()); 92 | app.user_data::() 93 | .expect("controller missing") 94 | .open_url(url.clone(), true, 0); 95 | }) 96 | ); 97 | } 98 | 99 | pub(super) fn edit_bookmarks(app: &mut Cursive) { 100 | let bookmarks = app 101 | .user_data::() 102 | .expect("controller missing") 103 | .bookmarks 104 | .lock() 105 | .unwrap() 106 | .get_bookmarks(); 107 | let mut view: SelectView = SelectView::new(); 108 | for b in bookmarks { 109 | let mut title = format!("{:<20}", b.title.clone().as_str()); 110 | title.truncate(20); 111 | let mut url = format!("{:<50}", b.url.clone().as_str()); 112 | url.truncate(50); 113 | view.add_item(format!("{} | {}", title, url), b); 114 | } 115 | app.add_layer( 116 | Dialog::new() 117 | .title("Edit bookmarks") 118 | .content(LinearLayout::vertical().child(view.with_name("bookmarks").scrollable())) 119 | .button("Delete", |app| { 120 | let selected = app 121 | .call_on_name("bookmarks", |view: &mut SelectView| { 122 | view.selection() 123 | }) 124 | .unwrap(); 125 | match selected { 126 | None => (), 127 | Some(bookmark) => { 128 | app.call_on_name("bookmarks", |view: &mut SelectView| { 129 | view.remove_item(view.selected_id().unwrap()); 130 | }) 131 | .unwrap(); 132 | 133 | Controller::remove_bookmark_action(app, (*bookmark).clone()); 134 | } 135 | } 136 | }) 137 | .button("Open", |app| { 138 | let selected = app 139 | .find_name::>("bookmarks") 140 | .expect("bookmarks view missing") 141 | .selection(); 142 | match selected { 143 | None => (), 144 | Some(b) => { 145 | app.user_data::() 146 | .expect("controller missing") 147 | .open_url(b.url.clone(), true, 0); 148 | } 149 | } 150 | app.pop_layer(); 151 | }) 152 | .button("Edit", |app| { 153 | let selected = app 154 | .call_on_name("bookmarks", |view: &mut SelectView| { 155 | view.selection() 156 | }) 157 | .unwrap(); 158 | match selected { 159 | None => (), 160 | Some(b) => { 161 | app.pop_layer(); 162 | crate::ui::dialogs::edit_bookmark( 163 | app, 164 | b.url.clone(), 165 | &b.title, 166 | &b.tags.join(","), 167 | ); 168 | } 169 | } 170 | }) 171 | .button("Close", |app| { 172 | app.pop_layer(); 173 | }), 174 | ); 175 | } 176 | 177 | pub(super) fn edit_history(app: &mut Cursive) { 178 | let entries = app 179 | .user_data::() 180 | .expect("controller missing") 181 | .history 182 | .lock() 183 | .unwrap() 184 | .get_latest_history(500) 185 | .expect("could not get latest history"); 186 | let mut view: SelectView = SelectView::new(); 187 | 188 | let format = format_description::parse( 189 | "[year]-[month]-[day] [hour]:[minute]:[second]" 190 | ).expect("Could not parse timestamp format"); 191 | for e in entries { 192 | let mut url = e.url.to_string(); 193 | url.truncate(50); 194 | view.add_item( 195 | format!( 196 | "{:>4}|{:<20}|{}", 197 | e.visited_count, 198 | e.timestamp.format(&format).expect("Invalid timestamp from database"), 199 | url 200 | ), 201 | e, 202 | ); 203 | } 204 | app.add_layer( 205 | Dialog::new() 206 | .title("Show history") 207 | .content( 208 | LinearLayout::vertical() 209 | .child(TextView::new("#Vis|Last Visited |URL")) 210 | .child(LinearLayout::vertical().child(view.with_name("entries").scrollable())), 211 | ) 212 | .button("Clear all history", |app| { 213 | app.add_layer( 214 | Dialog::around(TextView::new("Do you want to delete the history?")) 215 | .button("Cancel", |app| { 216 | app.pop_layer(); 217 | }) 218 | .button("Yes", |app| { 219 | app.pop_layer(); 220 | app.call_on_name("entries", |view: &mut SelectView| { 221 | view.clear() 222 | }); 223 | app.user_data::() 224 | .expect("controller missing") 225 | .clear_history(); 226 | }), 227 | ); 228 | }) 229 | .button("Open URL", |app| { 230 | let selected = app 231 | .find_name::>("entries") 232 | .unwrap() 233 | .selection(); 234 | app.pop_layer(); 235 | match selected { 236 | None => (), 237 | Some(b) => { 238 | app.user_data::() 239 | .expect("controller missing") 240 | .open_url(b.url.clone(), true, 0); 241 | } 242 | } 243 | }) 244 | .button("Close", |app| { 245 | // close dialog 246 | app.pop_layer(); 247 | }), 248 | ); 249 | } 250 | 251 | pub(crate) fn gemini_query(app: &mut Cursive, url: Url, query: String, secret: bool) { 252 | app.add_layer( 253 | Dialog::new() 254 | .title(query) 255 | .content( 256 | if secret { 257 | EditView::new().secret() 258 | } else { 259 | EditView::new() 260 | } 261 | // Call `show_popup` when the user presses `Enter` 262 | //FIXME: create closure with url: .on_submit(search) 263 | .with_name("query") 264 | .fixed_width(30), 265 | ) 266 | .button("Cancel", |app| { 267 | app.pop_layer(); 268 | }) 269 | .button("Ok", move |app| { 270 | let mut url = url.clone(); 271 | let name = app 272 | .find_name::("query") 273 | .expect("query field missing") 274 | .get_content(); 275 | app.pop_layer(); 276 | url.set_query(Some(&name)); 277 | Controller::open_url_action(app, url.as_str()); 278 | }), 279 | ); 280 | } 281 | 282 | pub(super) fn open_url(app: &mut Cursive) { 283 | open_given_url(app, None); 284 | } 285 | 286 | pub(super) fn open_current_url(app: &mut Cursive) { 287 | let current_url = app 288 | .user_data::() 289 | .expect("controller missing") 290 | .current_url 291 | .lock() 292 | .unwrap() 293 | .clone(); 294 | 295 | open_given_url(app, Some(current_url)); 296 | } 297 | 298 | fn open_given_url(app: &mut Cursive, url: Option) { 299 | app.add_layer( 300 | Dialog::new() 301 | .title("Enter gopher or gemini URL:") 302 | .content( 303 | EditView::new() 304 | .on_submit(|app, goto_url| { 305 | app.pop_layer(); 306 | Controller::open_url_action(app, goto_url); 307 | }) 308 | .content(match url { Some(url) => url.to_string(), None => "".to_string() }) 309 | .with_name("goto_url") 310 | .fixed_width(50), 311 | ) 312 | .button("Cancel", |app| { 313 | app.pop_layer(); 314 | }) 315 | .button("Ok", |app| { 316 | let goto_url = app 317 | .find_name::("goto_url") 318 | .expect("url field missing") 319 | .get_content(); 320 | app.pop_layer(); 321 | Controller::open_url_action(app, &goto_url) 322 | }), 323 | ); 324 | } 325 | 326 | pub(super) fn save_as(app: &mut Cursive) { 327 | let current_url = app 328 | .user_data::() 329 | .expect("controller missing") 330 | .current_url 331 | .lock() 332 | .unwrap() 333 | .clone(); 334 | 335 | let filename = download_filename_from_url(¤t_url); 336 | 337 | app.add_layer( 338 | Dialog::new() 339 | .title("Enter filename:") 340 | .content( 341 | EditView::new() 342 | .on_submit(Controller::save_as_action) 343 | .content(filename) 344 | .with_name("name") 345 | .fixed_width(50), 346 | ) 347 | .button("Cancel", |app| { 348 | app.pop_layer(); 349 | }) 350 | .button("Ok", |app| { 351 | let path = app.find_name::("name").unwrap().get_content(); 352 | Controller::save_as_action(app, &path); 353 | }), 354 | ); 355 | } 356 | 357 | pub(super) fn settings(app: &mut Cursive) { 358 | let download_path = SETTINGS.read().unwrap().config.download_path.clone(); 359 | let homepage_url = SETTINGS.read().unwrap().config.homepage.clone(); 360 | let theme = SETTINGS.read().unwrap().config.theme.clone(); 361 | let html_command = SETTINGS.read().unwrap().config.html_command.clone(); 362 | let image_command = SETTINGS.read().unwrap().config.image_command.clone(); 363 | let telnet_command = SETTINGS.read().unwrap().config.telnet_command.clone(); 364 | let darkmode = theme == "darkmode"; 365 | let textwrap = SETTINGS.read().unwrap().config.textwrap.clone(); 366 | let disable_history = SETTINGS.read().unwrap().config.disable_history; 367 | let disable_identities = SETTINGS.read().unwrap().config.disable_identities; 368 | app.add_layer( 369 | Dialog::new() 370 | .title("Settings") 371 | .content( 372 | LinearLayout::vertical() 373 | .child(TextView::new("Homepage:")) 374 | .child(EditView::new().content(homepage_url).with_name("homepage").fixed_width(50)) 375 | .child(TextView::new("Download path:")) 376 | .child(EditView::new().content(download_path.as_str()).with_name("download_path").fixed_width(50)) 377 | .child(TextView::new("\nUse full path to the external command executable.\nIt will be called with the URL as parameter.")) 378 | .child(TextView::new("HTML browser:")) 379 | .child(EditView::new().content(html_command.as_str()).with_name("html_command").fixed_width(50)) 380 | .child(TextView::new("Images viewer:")) 381 | .child(EditView::new().content(image_command.as_str()).with_name("image_command").fixed_width(50)) 382 | .child(TextView::new("Telnet client:")) 383 | .child(EditView::new().content(telnet_command.as_str()).with_name("telnet_command").fixed_width(50)) 384 | .child(DummyView) 385 | .child(LinearLayout::horizontal() 386 | .child(Checkbox::new().with_checked(darkmode).with_name("darkmode")) 387 | .child(DummyView) 388 | .child(TextView::new("Dark mode")) 389 | ) 390 | .child(LinearLayout::horizontal() 391 | .child(Checkbox::new().with_checked(disable_history).with_name("disable_history")) 392 | .child(DummyView) 393 | .child(TextView::new("Disable history recording")) 394 | ) 395 | .child(LinearLayout::horizontal() 396 | .child(Checkbox::new().with_checked(disable_identities).with_name("disable_identities")) 397 | .child(DummyView) 398 | .child(TextView::new("Disable identities")) 399 | ) 400 | .child(DummyView) 401 | .child(LinearLayout::horizontal() 402 | .child(TextView::new("Text wrap column:")) 403 | .child(DummyView) 404 | .child(EditView::new().content(textwrap.as_str()).with_name("textwrap").fixed_width(5)) 405 | ) 406 | ) 407 | .button("Apply", |app| { 408 | let homepage = app.find_name::("homepage").unwrap().get_content(); 409 | let download = app.find_name::("download_path").unwrap().get_content(); 410 | let darkmode = app.find_name::("darkmode").unwrap().is_checked(); 411 | let disable_history = app.find_name::("disable_history").unwrap().is_checked(); 412 | let disable_identities = app.find_name::("disable_identities").unwrap().is_checked(); 413 | let html_command = app.find_name::("html_command").unwrap().get_content(); 414 | let image_command = app.find_name::("image_command").unwrap().get_content(); 415 | let telnet_command = app.find_name::("telnet_command").unwrap().get_content(); 416 | let textwrap = app.find_name::("textwrap").unwrap().get_content(); 417 | app.pop_layer(); 418 | if Url::parse(&homepage).is_ok() { 419 | // only write to settings if data is correct 420 | SETTINGS.write().unwrap().config.homepage = homepage.to_string(); 421 | SETTINGS.write().unwrap().config.download_path = download.to_string(); 422 | SETTINGS.write().unwrap().config.html_command = html_command.to_string(); 423 | SETTINGS.write().unwrap().config.image_command = image_command.to_string(); 424 | SETTINGS.write().unwrap().config.telnet_command = telnet_command.to_string(); 425 | SETTINGS.write().unwrap().config.textwrap = textwrap.to_string(); 426 | SETTINGS.write().unwrap().config.disable_history = disable_history; 427 | SETTINGS.write().unwrap().config.disable_identities = disable_identities; 428 | let theme = if darkmode { "darkmode" } else { "lightmode" }; 429 | app.load_toml(SETTINGS.read().unwrap().get_theme_by_name(theme.to_string())).unwrap(); 430 | SETTINGS.write().unwrap().config.theme = theme.to_string(); 431 | 432 | if let Err(why) = SETTINGS.write().unwrap().write_settings_to_file() { 433 | app.add_layer(Dialog::info(format!("Could not write config file: {}", why))); 434 | } 435 | } else { 436 | app.add_layer(Dialog::info("Invalid homepage url")); 437 | } 438 | }) 439 | .button("Cancel", |app| { 440 | app.pop_layer(); 441 | }) 442 | ); 443 | } 444 | 445 | pub(crate) fn manage_client_certificates(app: &mut Cursive) { 446 | let client_certificates = app 447 | .user_data::() 448 | .expect("controller missing") 449 | .client_certificates 450 | .lock() 451 | .unwrap() 452 | .get_client_certificates(); 453 | let mut view: SelectView = SelectView::new(); 454 | for cc in client_certificates { 455 | let mut common_name = format!("{:<30}", cc.common_name.clone().as_str()); 456 | let format = 457 | format_description::parse("[year]-[month]-[day]").expect("Could not parse date format"); 458 | let now = OffsetDateTime::now_utc().date(); 459 | let expiration_date = format!("{:<10}", cc.expiration_date.format(&format).unwrap()); 460 | let warning = if now > cc.expiration_date { "!" } else { " " }; 461 | 462 | let urls = app 463 | .user_data::() 464 | .expect("controller missing") 465 | .client_certificates 466 | .lock() 467 | .unwrap() 468 | .get_urls_for_certificate(&cc.fingerprint); 469 | let used_on = match urls.len() { 470 | 0 => "Unused".to_string(), 471 | 1 => "1 URL".to_string(), 472 | _ => format!("{} URLs", urls.len()), 473 | }; 474 | 475 | common_name.truncate(30); 476 | view.add_item( 477 | format!( 478 | "{} | {}{} | {}", 479 | common_name, warning, expiration_date, used_on 480 | ), 481 | cc, 482 | ); 483 | } 484 | app.add_layer( 485 | Dialog::new() 486 | .title("Edit identities") 487 | .content( 488 | LinearLayout::vertical().child(view.with_name("client_certificates").scrollable()), 489 | ) 490 | .button("Create identity", |app| { 491 | app.pop_layer(); 492 | add_client_certificate(app, None); 493 | }) 494 | .button("Delete", |app| { 495 | let selected = app 496 | .call_on_name( 497 | "client_certificates", 498 | |view: &mut SelectView| view.selection(), 499 | ) 500 | .unwrap(); 501 | app.add_layer( 502 | Dialog::around(TextView::new("Do you really want to delete this identity?")) 503 | .button("Delete", move |app| { 504 | app.pop_layer(); // Confirm dialog 505 | match &selected { 506 | None => (), 507 | Some(client_certificate) => { 508 | app.call_on_name( 509 | "client_certificates", 510 | |view: &mut SelectView| { 511 | view.remove_item(view.selected_id().unwrap()); 512 | }, 513 | ) 514 | .unwrap(); 515 | Controller::remove_client_certificate_action( 516 | app, 517 | client_certificate, 518 | ); 519 | } 520 | } 521 | }) 522 | .dismiss_button("Cancel"), 523 | ); 524 | }) 525 | .button("Edit", |app| { 526 | let selected = app 527 | .call_on_name( 528 | "client_certificates", 529 | |view: &mut SelectView| view.selection(), 530 | ) 531 | .unwrap(); 532 | if let Some(cc) = selected { 533 | app.pop_layer(); 534 | crate::ui::dialogs::edit_client_certificate(app, (*cc).clone()); 535 | }; 536 | }) 537 | .button("Close", |app| { 538 | app.pop_layer(); 539 | }), 540 | ); 541 | } 542 | 543 | pub(crate) fn choose_client_certificate(app: &mut Cursive, url: Url) { 544 | let client_certificates = app 545 | .user_data::() 546 | .expect("controller missing") 547 | .client_certificates 548 | .lock() 549 | .unwrap() 550 | .get_client_certificates(); 551 | let mut view: SelectView = SelectView::new(); 552 | for cc in client_certificates { 553 | let mut common_name = format!("{:<30}", cc.common_name.clone().as_str()); 554 | let format = 555 | format_description::parse("[year]-[month]-[day]").expect("Could not parse date format"); 556 | let now = OffsetDateTime::now_utc().date(); 557 | let expiration_date = format!("{:<10}", cc.expiration_date.format(&format).unwrap()); 558 | let warning = if now > cc.expiration_date { "!" } else { " " }; 559 | 560 | let urls = app 561 | .user_data::() 562 | .expect("controller missing") 563 | .client_certificates 564 | .lock() 565 | .unwrap() 566 | .get_urls_for_certificate(&cc.fingerprint); 567 | let used_on = match urls.len() { 568 | 0 => "Unused".to_string(), 569 | 1 => "1 URL".to_string(), 570 | _ => format!("{} URLs", urls.len()), 571 | }; 572 | 573 | common_name.truncate(30); 574 | view.add_item( 575 | format!( 576 | "{} | {}{} | {}", 577 | common_name, warning, expiration_date, used_on 578 | ), 579 | cc, 580 | ); 581 | } 582 | let original_url = url.clone(); 583 | app.add_layer( 584 | Dialog::new() 585 | .title("Choose identity") 586 | .content( 587 | LinearLayout::vertical() 588 | .child(TextView::new( 589 | "The current gemini site requests a client certificate.\n\ 590 | Select an identity or create a new one to continue.", 591 | )) 592 | .child(DummyView) 593 | .child(view.with_name("client_certificates").scrollable()), 594 | ) 595 | .button("Create identity", move |app| { 596 | app.pop_layer(); 597 | add_client_certificate(app, Some(original_url.clone())); 598 | }) 599 | .button("Use identity", move |app| { 600 | let selected = app 601 | .call_on_name( 602 | "client_certificates", 603 | |view: &mut SelectView| view.selection(), 604 | ) 605 | .unwrap(); 606 | if let Some(cc) = selected { 607 | app.pop_layer(); 608 | let controller = app.user_data::().expect("controller missing"); 609 | let mut guard = controller.client_certificates.lock().unwrap(); 610 | guard.use_current_site(&url, &cc.fingerprint); 611 | drop(guard); 612 | controller.fetch_gemini_url(url.clone(), 0); 613 | }; 614 | }) 615 | .button("Cancel", |app| { 616 | app.pop_layer(); 617 | }), 618 | ); 619 | } 620 | 621 | pub enum UrlOriginType { 622 | DecideLater, 623 | CurrentHost, 624 | CurrentUrl, 625 | SpecifiedUrl, 626 | } 627 | 628 | pub fn add_client_certificate(app: &mut Cursive, url: Option) { 629 | /* 630 | - New certificate - 631 | | Common name: | 632 | | _________________ | 633 | |---------------------------| 634 | | Use on: | 635 | | [Not used] | 636 | | [Current host] | 637 | | [Current URL] | 638 | | [This URL:] | 639 | | Enter URL: | 640 | | _________________ | 641 | |---------------------------| 642 | | Valid until (YYYY-MM-DD): | 643 | | _________________ | 644 | |---------------------------| 645 | | Notes: | 646 | | _________________ | 647 | | _________________ | 648 | | | 649 | | | 650 | */ 651 | 652 | let mut valid_for_group: RadioGroup = RadioGroup::new(); 653 | valid_for_group.set_on_change(|app, selected| { 654 | let mut specified_url = app.find_name::("specified_url").unwrap(); 655 | let controller = app.user_data::().expect("controller missing"); 656 | let current_url = controller.current_url.lock().unwrap().clone(); 657 | 658 | // For current urls: drop URL parameters 659 | let u = Url::parse(¤t_url[..Position::AfterPath]).unwrap(); 660 | let mut current_host = Url::parse("gemini://example.com").expect("Unable to parse url"); 661 | current_host.set_host(current_url.host_str()).ok(); 662 | current_host.set_port(current_url.port()).ok(); 663 | current_host.set_scheme("gemini").ok(); 664 | match selected { 665 | UrlOriginType::DecideLater => specified_url.set_content(""), 666 | UrlOriginType::CurrentUrl => specified_url.set_content(u), 667 | UrlOriginType::CurrentHost => specified_url.set_content(current_host), 668 | UrlOriginType::SpecifiedUrl => specified_url.set_content("gemini://host"), 669 | }; 670 | }); 671 | 672 | // Calculate default expiry date: 673 | let odt: OffsetDateTime = SystemTime::now().into(); 674 | let mut date: Date = odt.date(); 675 | date = date 676 | .replace_year(date.year() + 1) 677 | .expect("Cannot create expiry date"); 678 | let format = 679 | format_description::parse("[year]-[month]-[day]").expect("Could not parse date format"); 680 | let expiry_date = date.format(&format).unwrap(); 681 | let original_url = url.clone(); 682 | 683 | app.add_layer( 684 | Dialog::new() 685 | .title("New identity") 686 | .content( 687 | LinearLayout::vertical() 688 | .child(TextView::new("Name:")) 689 | .child( 690 | EditView::new() 691 | .with_name("common_name") 692 | .fixed_width(40), 693 | ) 694 | .child(DummyView) 695 | .child(TextView::new("Use on:")) 696 | .child( 697 | LinearLayout::vertical() 698 | .child(valid_for_group.button(UrlOriginType::DecideLater, "Decide later")) 699 | .child(valid_for_group.button(UrlOriginType::CurrentHost, "Current host")) 700 | .child(valid_for_group.button(UrlOriginType::CurrentUrl, "Current URL").with_name("current_url_button")) 701 | .child(valid_for_group.button(UrlOriginType::SpecifiedUrl, "Specified URL:").with_name("specified_url_button")) 702 | .child(EditView::new() 703 | .on_edit(move |app, _text, _cursor| { 704 | app.find_name::>("specified_url_button").unwrap().select(); 705 | }) 706 | .with_name("specified_url") 707 | .fixed_width(40) 708 | ) 709 | ) 710 | .child(DummyView) 711 | .child(TextView::new("Valid until (YYYY-MM-DD):")) 712 | .child( 713 | EditView::new() 714 | .content(expiry_date.as_str()) 715 | .with_name("valid_until") 716 | .fixed_width(40), 717 | ) 718 | .child(DummyView) 719 | .child(TextView::new("Notes:")) 720 | .child(TextArea::new() 721 | .with_name("notes") 722 | .fixed_width(40) 723 | .min_height(2) 724 | ) 725 | ) 726 | .button("Ok", move |app| { 727 | let common_name = app.find_name::("common_name").unwrap().get_content(); 728 | let notes = app.find_name::