;; Author: Tim Vaughan <timv@ughan.xyz>
;; Created: 11 April 2019
-;; Version: 2.5.2
+;; Version: 2.7.0
;; Keywords: comm gopher
;; Homepage: http://thelambdalab.xyz/elpher
;; Package-Requires: ((emacs "26"))
;; - direct visualisation of image files,
;; - a simple bookmark management system,
;; - connections using TLS encryption,
-;; - the fledgling Gemini protocol.
+;; - the fledgling Gemini protocol,
+;; - the greybeard Finger protocol.
;; To launch Elpher, simply use 'M-x elpher'. This will open a start
;; page containing information on key bindings and suggested starting
(require 'subr-x)
(require 'dns)
(require 'ansi-color)
+(require 'nsm)
;;; Global constants
;;
-(defconst elpher-version "2.5.2"
+(defconst elpher-version "2.7.0"
"Current version of elpher.")
(defconst elpher-margin-width 6
((gopher ?s) elpher-get-gopher-page elpher-render-download "snd" elpher-binary)
((gopher ?h) elpher-get-gopher-page elpher-render-html "htm" elpher-html)
(gemini elpher-get-gemini-page elpher-render-gemini "gem" elpher-gemini)
+ (finger elpher-get-finger-page elpher-render-text "txt" elpher-text)
(telnet elpher-get-telnet-page nil "tel" elpher-telnet)
(other-url elpher-get-other-url-page nil "url" elpher-other-url)
((special bookmarks) elpher-get-bookmarks-page nil "/" elpher-index)
"A gopher client."
:group 'applications)
+;; General appearance and customizations
+
+(defcustom elpher-open-urls-with-eww nil
+ "If non-nil, open URL selectors using eww.
+Otherwise, use the system browser via the BROWSE-URL function."
+ :type '(boolean))
+
+(defcustom elpher-use-header t
+ "If non-nil, display current page information in buffer header."
+ :type '(boolean))
+
+(defcustom elpher-auto-disengage-TLS nil
+ "If non-nil, automatically disengage TLS following an unsuccessful connection.
+While enabling this may seem convenient, it is also potentially dangerous as it
+allows switching from an encrypted channel back to plain text without user input."
+ :type '(boolean))
+
+(defcustom elpher-connection-timeout 5
+ "Specifies the number of seconds to wait for a network connection to time out."
+ :type '(integer))
+
+(defcustom elpher-filter-ansi-from-text nil
+ "If non-nil, filter ANSI escape sequences from text.
+The default behaviour is to use the ansi-color package to interpret these
+sequences."
+ :type '(boolean))
+
+(defcustom elpher-gemini-TLS-cert-checks nil
+ "If non-nil, verify gemini server TLS certs using the default security level.
+Otherwise, certificate verification is disabled.
+
+This defaults to off because it is standard practice for Gemini servers
+to use self-signed certificates, meaning that most servers provide what
+EMACS considers to be an invalid certificate."
+ :type '(boolean))
+
+(defcustom elpher-gemini-max-fill-width 80
+ "Specify the maximum default width (in columns) of text/gemini documents.
+The actual width used is the minimum of this value and the window width at
+the time when the text is rendered."
+ :type '(integer))
+
;; Face customizations
+(defgroup elpher-faces nil
+ "Elpher face customizations."
+ :group 'elpher)
+
(defface elpher-index
'((t :inherit font-lock-keyword-face))
"Face used for directory type directory records.")
(defface elpher-gemini
'((t :inherit font-lock-regexp-grouping-backslash))
- "Face used for html type directory records.")
+ "Face used for Gemini type directory records.")
(defface elpher-other-url
'((t :inherit font-lock-comment-face))
'((t :inherit shadow))
"Face used for brackets around directory margin key.")
-;; Other customizations
-
-(defcustom elpher-open-urls-with-eww nil
- "If non-nil, open URL selectors using eww.
-Otherwise, use the system browser via the BROWSE-URL function."
- :type '(boolean))
-
-(defcustom elpher-use-header t
- "If non-nil, display current page information in buffer header."
- :type '(boolean))
-
-(defcustom elpher-auto-disengage-TLS nil
- "If non-nil, automatically disengage TLS following an unsuccessful connection.
-While enabling this may seem convenient, it is also potentially dangerous as it
-allows switching from an encrypted channel back to plain text without user input."
- :type '(boolean))
+(defface elpher-gemini-heading1
+ '((t :inherit bold :height 1.8))
+ "Face used for brackets around directory margin key.")
-(defcustom elpher-connection-timeout 5
- "Specifies the number of seconds to wait for a network connection to time out."
- :type '(integer))
+(defface elpher-gemini-heading2
+ '((t :inherit bold :height 1.5))
+ "Face used for brackets around directory margin key.")
-(defcustom elpher-filter-ansi-from-text nil
- "If non-nil, filter ANSI escape sequences from text.
-The default behaviour is to use the ansi-color package to interpret these
-sequences."
- :type '(boolean))
+(defface elpher-gemini-heading3
+ '((t :inherit bold :height 1.2))
+ "Face used for brackets around directory margin key.")
;;; Model
;;
'gemini)
((equal protocol "telnet")
'telnet)
+ ((equal protocol "finger")
+ 'finger)
(t 'other-url)))))
(defun elpher-address-protocol (address)
"Retrieve host from ADDRESS object."
(url-host address))
+(defun elpher-address-user (address)
+ "Retrieve user from ADDRESS object."
+ (url-user address))
+
(defun elpher-address-port (address)
"Retrieve port from ADDRESS object.
If no address is defined, returns 0. (This is for compatibility with the URL library.)"
(let ((previous-page (pop elpher-history)))
(if previous-page
(elpher-visit-page previous-page nil t)
- (error "No previous page."))))
+ (error "No previous page"))))
(defun elpher-reload-current-page ()
"Reload the current page, discarding any existing cached content."
(list 'with-current-buffer "*elpher*"
'(elpher-mode)
(append (list 'let '((inhibit-read-only t))
+ '(setq-local network-security-level
+ (default-value 'network-security-level))
'(erase-buffer)
'(elpher-update-header))
args)))
(let* ((kill-buffer-query-functions nil)
(port (elpher-address-port address))
(host (elpher-address-host address))
- (selector-string "")
+ (selector-string-parts nil)
(proc (open-network-stream "elpher-process"
nil
(if force-ipv4 (dns-query host) host)
(set-process-filter proc
(lambda (_proc string)
(cancel-timer timer)
- (setq selector-string
- (concat selector-string string))))
+ (setq selector-string-parts
+ (cons string selector-string-parts))))
(set-process-sentinel proc
(lambda (_proc event)
(condition-case the-error
"\r\n"))))
(t
(cancel-timer timer)
- (funcall renderer selector-string)
+ (funcall renderer (apply #'concat
+ (reverse selector-string-parts)))
(elpher-restore-pos)))
(error
(elpher-network-error address the-error))))))
(if type-map-entry
(let* ((margin-code (elt type-map-entry 2))
(face (elt type-map-entry 3))
- (page (elpher-make-page display-string address)))
+ (filtered-display-string (ansi-color-filter-apply display-string))
+ (page (elpher-make-page filtered-display-string address)))
(elpher-insert-margin margin-code)
- (insert-text-button display-string
+ (insert-text-button filtered-display-string
'face face
'elpher-page page
'action #'elpher-click-link
"Retrieve gemini ADDRESS, then render using RENDERER.
If FORCE-IPV4 is non-nil, explicitly look up and use IPv4 address corresponding
to ADDRESS."
+ (unless elpher-gemini-TLS-cert-checks
+ (setq-local network-security-level 'low))
(if (not (gnutls-available-p))
(error "Cannot establish gemini connection: GnuTLS not available")
(unless (< (elpher-address-port address) 65536)
(let* ((kill-buffer-query-functions nil)
(port (elpher-address-port address))
(host (elpher-address-host address))
- (response-string "")
+ (response-string-parts nil)
(proc (open-network-stream "elpher-process"
nil
(if force-ipv4 (dns-query host) host)
(when timer
(cancel-timer timer)
(setq timer nil))
- (setq response-string
- (concat response-string string))))
+ (setq response-string-parts
+ (cons string response-string-parts))))
(set-process-sentinel proc
(lambda (proc event)
(condition-case the-error
(concat (elpher-address-to-url address)
"\r\n"))))
((string-prefix-p "deleted" event)) ; do nothing
- ((and (string-empty-p response-string)
+ ((and (not response-string-parts)
(not force-ipv4))
; Try again with IPv4
(message "Connection failed. Retrying with IPv4.")
(elpher-get-gemini-response address renderer t))
(t
(funcall #'elpher-process-gemini-response
- response-string
+ (apply #'concat (reverse response-string-parts))
renderer)
(elpher-restore-pos)))
(error
(elpher-collapse-dot-sequences (url-filename address)))))
address))
+(defun elpher-gemini-insert-link (link-line)
+ "Insert link described by LINK-LINE into a text/gemini document."
+ (let* ((url (elpher-gemini-get-link-url link-line))
+ (display-string (let ((s (elpher-gemini-get-link-display-string link-line)))
+ (if (string-empty-p s) url s)))
+ (address (elpher-address-from-gemini-url url))
+ (type (if address (elpher-address-type address) nil))
+ (type-map-entry (cdr (assoc type elpher-type-map))))
+ (insert "→ ")
+ (if type-map-entry
+ (let* ((face (elt type-map-entry 3))
+ (filtered-display-string (ansi-color-filter-apply display-string))
+ (page (elpher-make-page filtered-display-string address)))
+ (insert-text-button filtered-display-string
+ 'face face
+ 'elpher-page page
+ 'action #'elpher-click-link
+ 'follow-link t
+ 'help-echo (elpher-page-button-help page)))
+ (insert (propertize display-string 'face 'elpher-unknown)))
+ (insert "\n")))
+
+(defun elpher-gemini-insert-header (header-line)
+ "Insert header described by HEADER-LINE into a text/gemini document.
+The gemini map file line describing the header is given
+by HEADER-LINE."
+ (when (string-match "^\\(#+\\)[ \t]*" header-line)
+ (let ((level (length (match-string 1 header-line)))
+ (header (substring header-line (match-end 0))))
+ (unless (display-graphic-p)
+ (insert (make-string level ?#) " "))
+ (insert (propertize header 'face
+ (pcase level
+ (1 'elpher-gemini-heading1)
+ (2 'elpher-gemini-heading2)
+ (3 'elpher-gemini-heading3)
+ (_ 'default)))
+ "\n"))))
+
(defun elpher-render-gemini-map (data _parameters)
"Render DATA as a gemini map file, PARAMETERS is currently unused."
(elpher-with-clean-buffer
- (dolist (line (split-string data "\n"))
- (if (string-prefix-p "=>" line)
- (let* ((url (elpher-gemini-get-link-url line))
- (display-string (elpher-gemini-get-link-display-string line))
- (address (elpher-address-from-gemini-url url)))
- (if (> (length display-string) 0)
- (elpher-insert-index-record display-string address)
- (elpher-insert-index-record url address)))
- (elpher-insert-index-record line)))
+ (let ((preformatted nil))
+ (auto-fill-mode 1)
+ (setq-local fill-column (min (window-width) elpher-gemini-max-fill-width))
+ (dolist (line (split-string data "\n"))
+ (cond
+ ((string-prefix-p "```" line) (setq preformatted (not preformatted)))
+ (preformatted (insert (elpher-process-text-for-display line) "\n"))
+ ((string-prefix-p "=>" line) (elpher-gemini-insert-link line))
+ ((string-prefix-p "#" line) (elpher-gemini-insert-header line))
+ (t (insert (elpher-process-text-for-display line)) (newline)))))
(elpher-cache-content
(elpher-page-address elpher-current-page)
(buffer-string))))
(elpher-page-address elpher-current-page)
(buffer-string))))
+;; Finger page connection
+
+(defun elpher-get-finger-page (renderer &optional force-ipv4)
+ "Opens a finger connection to the current page address and renders it using RENDERER."
+ (let* ((address (elpher-page-address elpher-current-page))
+ (content (elpher-get-cached-content address)))
+ (if (and content (funcall renderer nil))
+ (elpher-with-clean-buffer
+ (insert content)
+ (elpher-restore-pos))
+ (elpher-with-clean-buffer
+ (insert "LOADING... (use 'u' to cancel)"))
+ (condition-case the-error
+ (let* ((kill-buffer-query-functions nil)
+ (user (let ((filename (elpher-address-filename address)))
+ (if (> (length filename) 1)
+ (substring filename 1)
+ (elpher-address-user address))))
+ (port (let ((given-port (elpher-address-port address)))
+ (if (> given-port 0) given-port 79)))
+ (host (elpher-address-host address))
+ (selector-string-parts nil)
+ (proc (open-network-stream "elpher-process"
+ nil
+ (if force-ipv4 (dns-query host) host)
+ port
+ :type 'plain
+ :nowait t))
+ (timer (run-at-time elpher-connection-timeout
+ nil
+ (lambda ()
+ (pcase (process-status proc)
+ ('connect
+ (elpher-process-cleanup)
+ (unless force-ipv4
+ (message "Connection timed out. Retrying with IPv4 address.")
+ (elpher-get-finger-page renderer t))))))))
+ (setq elpher-network-timer timer)
+ (set-process-coding-system proc 'binary)
+ (set-process-filter proc
+ (lambda (_proc string)
+ (cancel-timer timer)
+ (setq selector-string-parts
+ (cons string selector-string-parts))))
+ (set-process-sentinel proc
+ (lambda (_proc event)
+ (condition-case the-error
+ (cond
+ ((string-prefix-p "deleted" event))
+ ((string-prefix-p "open" event)
+ (let ((inhibit-eol-conversion t))
+ (process-send-string
+ proc
+ (concat user "\r\n"))))
+ (t
+ (cancel-timer timer)
+ (funcall renderer (apply #'concat
+ (reverse selector-string-parts)))
+ (elpher-restore-pos)))))))
+ (error
+ (elpher-network-error address the-error))))))
+
+
;; Other URL page opening
(defun elpher-get-other-url-page (renderer)
(let ((address-copy (elpher-address-from-url
(elpher-address-to-url address))))
(setf (url-filename address-copy) "")
- (elpher-visit-page
- (elpher-make-page (elpher-address-to-url address-copy)
- address-copy))))
+ (elpher-go (elpher-address-to-url address-copy))))
(error "Command invalid for %s" (elpher-page-display-string elpher-current-page)))))
(defun elpher-bookmarks-current-p ()