package main import ( "bytes" "errors" "fmt" "io" "net/url" "os" "os/exec" "path/filepath" "strconv" "strings" "syscall" "tildegit.org/tjp/sliderule" "tildegit.org/tjp/sliderule/gemini" "tildegit.org/tjp/sliderule/gopher" ) var client sliderule.Client func init() { client = sliderule.NewClient(nil) } var ( ErrMustBeOnAPage = errors.New("you must be on a page to do that, use the \"go\" command first") ErrNoPreviousHistory = errors.New("there is no previous page in the history") ErrNoNextHistory = errors.New("there is no page to go forward to") ErrOnLastLink = errors.New("already on the last link") ErrOnFirstLink = errors.New("already on the first link") ErrCantMoveRelative = errors.New("next/previous only work after navigating to a link on a page") ErrAlreadyAtTop = errors.New("already at the site root") ErrInvalidNumericLink = errors.New("no link with that number") ErrInvalidLink = errors.New("that doesn't look like a valid URL") ErrSaveNeedsFilename = errors.New("save requires a filename argument") ErrInvalidMarkArgs = errors.New("mark what?") ErrInvalidTourArgs = errors.New("tour what?") ) func About(state *BrowserState) error { _, err := fmt.Println(` ... *=:::. ---======- -+-..... .#@@@@@@@= .+=-:....:++ +@@@@@@@@# :-::.. ...*+ :%@@@@@@@@@. =*+++==:.... .*@@@@@@@@@@= -#%###**++-:.. -@@@@@@@@@@@% .::-=*#@@#*#@@@@@@@%-. ..:::----=====++#@@@@@@@@@@@@%%%@@@@@@@@@@%%#**@@@@@@@= .-+#%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@- =*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##%@@*%@@@@@@@@@@- .. -+*. .#@@@@@@@@@@@@@@@@@@@@@@@@@@@%@@@@@@%%%%###- =- .=.==----==--:... ...:::::=#@@@@#**+*##@@@#*%%%%%%%%##############******+==-:-=-:.-==-:.. ... .:.:-==++++====::-----::---:-+%@@@@@@@@@@@@@#-... . ...............=@@@@@@@@@@@@@@+.... -@@@@@@@@@@@@@#. -@@@@@@@@@@@@%- -@@%%##@@@@@@= =%++*=-=%@@@*. =--*%%%::#@#: *@+=#-+*%@%- .*%****###@+. ::..:::.::*@@@%%%%%#: x-1 is a browser for small web protocols gemini, gopher, finger, spartan, and nex. It also has very limited support for HTTP[S]. It was written by TJP and released to the public domain. `[1:]) return err } func Navigate(state *BrowserState, target *url.URL, navIndex int, conf *Config) error { hist := state.History if hist.Url == nil || target.String() != hist.Url.String() { state.History = &History{ Url: target, Depth: hist.Depth + 1, Back: hist, NavIndex: navIndex, } hist.Forward = state.History } state.Modal = nil return Reload(state, conf) } func gopherURL(u *url.URL) (string, sliderule.Status) { if u.Scheme != "gopher" || len(u.Path) < 2 || !strings.HasPrefix(u.Path, "/") { return u.String(), 0 } itemType := u.Path[1] clone := *u clone.Path = u.Path[2:] return clone.String(), sliderule.Status(itemType) } func Reload(state *BrowserState, conf *Config) error { if state.Url == nil { return ErrMustBeOnAPage } urlStr, itemType := gopherURL(state.Url) if itemType == gopher.SearchType && state.Url.RawQuery == "" { state.Readline.SetPrompt("query: ") line, err := state.Readline.Readline() if err != nil { return err } state.Url.RawQuery = url.QueryEscape(strings.TrimRight(line, "\n")) urlStr, _ = gopherURL(state.Url) } var response *sliderule.Response var err error if state.Url.Scheme == "spartan" && state.Url.Fragment == "prompt" { input, err := externalMessage() if err != nil { return err } body := io.LimitReader(bytes.NewBuffer(input), int64(len(input))) state.Url.Fragment = "" response, err = client.Upload(state.Url.String(), body) state.Url.Fragment = "prompt" if err != nil { return err } } else { response, err = client.Fetch(urlStr) if err != nil { return err } } if state.Url.Scheme == "gemini" { switch response.Status { case gemini.StatusInput: state.Readline.SetPrompt("input: ") line, err := state.Readline.Readline() if err != nil { return err } state.Url.RawQuery = url.QueryEscape(strings.TrimRight(line, "\n")) response, err = client.Fetch(state.Url.String()) if err != nil { return err } case gemini.StatusSensitiveInput: line, err := state.Readline.ReadPassword("password: ") if err != nil { return err } state.Url.RawQuery = url.QueryEscape(strings.TrimRight(string(line), "\n")) response, err = client.Fetch(state.Url.String()) if err != nil { return err } } } state.DocType = docType(state.Url, response) state.Body, err = io.ReadAll(response.Body) if err != nil { return err } state.Formatted, state.Links, err = parseDoc(state.DocType, state.Body, conf) if err != nil { return err } return print(state) } func externalMessage() ([]byte, error) { tmpf, err := os.CreateTemp("", "*") if err != nil { return nil, err } defer func() { _ = os.Remove(tmpf.Name()) }() prompt := []byte("# enter input below (this line will be ignored)\n") err = (func() error { defer func() { _ = tmpf.Close() }() if _, err := tmpf.Write(prompt); err != nil { return err } return nil }()) if err != nil { return nil, err } editor := os.Getenv("EDITOR") if editor == "" { editor = "vi" } editor, err = exec.LookPath(editor) if err != nil { return nil, err } cmd := exec.Command(editor, tmpf.Name()) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return nil, err } tmpf, err = os.Open(tmpf.Name()) if err != nil { return nil, err } defer func() { _ = tmpf.Close() }() buf, err := io.ReadAll(tmpf) if err != nil { return nil, err } return bytes.TrimPrefix(buf, prompt), nil } func back(state *BrowserState) error { if state.Back == nil { return ErrNoPreviousHistory } state.History = state.Back state.Modal = nil return nil } func Back(state *BrowserState, num int) error { for i := 0; i < num; i += 1 { if err := back(state); err != nil { return err } } return print(state) } func Forward(state *BrowserState, num int) error { for i := 0; i < num; i += 1 { if state.Forward == nil { return ErrNoNextHistory } state.History = state.Forward } state.Modal = nil return print(state) } func Next(state *BrowserState, conf *Config) error { switch state.NavIndex { case -1: return ErrCantMoveRelative case len(state.Back.Links) - 1: return ErrOnLastLink } index := state.NavIndex + 1 if err := back(state); err != nil { return err } u := state.Url.ResolveReference(state.Links[index].Target) return Navigate(state, u, index, conf) } func Previous(state *BrowserState, conf *Config) error { switch state.NavIndex { case -1: return ErrCantMoveRelative case 0: return ErrOnFirstLink } index := state.NavIndex - 1 if err := back(state); err != nil { return err } u := state.Url.ResolveReference(state.Links[index].Target) return Navigate(state, u, index, conf) } func Root(state *BrowserState, tilde bool, conf *Config) error { if state.Url == nil { return ErrMustBeOnAPage } u := *state.Url u.RawQuery = "" u.Fragment = "" base := "/" if u.Scheme == "gopher" { base = "/1/" } if tilde && strings.HasPrefix(u.Path, "/~") { u.Path = base + strings.SplitN(u.Path, "/", 3)[1] + "/" } else { u.Path = base } return Navigate(state, &u, -1, conf) } func Up(state *BrowserState, conf *Config) error { if state.Url == nil { return ErrMustBeOnAPage } u := *state.Url u.Path = strings.TrimSuffix(u.Path, "/") if u.Path == "" { return ErrAlreadyAtTop } u.Path = u.Path[:strings.LastIndex(u.Path, "/")+1] return Navigate(state, &u, -1, conf) } func Go(state *BrowserState, dest string, conf *Config) error { u, idx, err := parseURL(dest, state, conf.DefaultScheme) if err != nil { return err } return Navigate(state, u, idx, conf) } func parseURL(str string, state *BrowserState, defaultScheme string) (*url.URL, int, error) { if str == "." { if state.Url == nil { return nil, -1, ErrMustBeOnAPage } return state.Url, -1, nil } if strings.HasPrefix(str, "m:") { _, target, err := findMark(state, str[2:]) if err != nil { return nil, -1, err } str = target } else if strings.HasPrefix(str, "t:") { i, err := strconv.Atoi(str[2:]) if err != nil { return nil, -1, ErrInvalidLink } if i < 0 || i >= len(state.CurrentTour.Links) { return nil, -1, ErrInvalidTourPos } return state.CurrentTour.Links[i], -1, nil } else if strings.HasPrefix(str, "t[") { idx := strings.IndexByte(str, ']') if idx < 0 || idx >= len(str)-2 || str[idx+1] != ':' { return nil, -1, ErrInvalidLink } if i, err := strconv.Atoi(str[idx+2:]); err != nil { return nil, -1, ErrInvalidLink } else { tour, err := findTour(state, str[2:idx]) if err != nil { return nil, -1, err } if i < 0 || i >= len(tour.Links) { return nil, -1, ErrInvalidTourPos } return tour.Links[i], -1, nil } } var u *url.URL i, err := strconv.Atoi(str) if err == nil { if len(state.Links) <= i { return nil, -1, ErrInvalidNumericLink } u = state.Links[i].Target if state.Links[i].Prompt { u.Fragment = "prompt" } } else { i = -1 u, err = url.Parse(str) if err != nil { return nil, -1, ErrInvalidLink } if u.Scheme == "" { u.Scheme = defaultScheme } } if state.Url != nil { u = state.Url.ResolveReference(u) } if u.Hostname() == "" { return nil, -1, ErrInvalidLink } return u, i, nil } func print(state *BrowserState) error { if state.Quiet { return nil } defer func() { state.Modal = nil }() if state.Body == nil && state.Modal == nil { return ErrMustBeOnAPage } out := []byte(state.Formatted) if state.Modal != nil { out = state.Modal } if state.Modal != nil || state.Pager == "never" { _, err := os.Stdout.Write(out) return err } lessarg := []string{} switch state.Pager { case "auto": lessarg = []string{"-F"} fallthrough case "always": less, err := exec.LookPath("less") if err != nil { return err } cmd := exec.Command(less, lessarg...) cmd.Stdin = bytes.NewBuffer(out) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } return errors.New("invalid 'pager' value in configuration") } func Print(state *BrowserState) error { q := state.Quiet defer func() { state.Quiet = q }() state.Quiet = false return print(state) } func Links(state *BrowserState, conf *Config) error { if state.Links == nil { return ErrMustBeOnAPage } buf := &bytes.Buffer{} for _, link := range state.Links { fmt.Fprintf(buf, "=> %s %s\n", link.Target.String(), link.Text) } formatted, _, err := parseDoc("text/gemini", buf.Bytes(), conf) if err != nil { return err } state.Modal = []byte(formatted) return Print(state) } func HistoryCmd(state *BrowserState) error { if state.History == nil { return ErrMustBeOnAPage } h := state.History i := 0 for h.Forward != nil { h = h.Forward i += 1 } buf := &bytes.Buffer{} j := 0 for h.Url != nil { mark := "" if j == i { mark = "* " } fmt.Fprintf(buf, "%s%s\n", mark, h.Url.String()) j += 1 h = h.Back } state.Modal = buf.Bytes() return Print(state) } func Save(state *BrowserState, filename string, conf *Config) error { if state.Body == nil { return ErrMustBeOnAPage } if filename == "" { return ErrSaveNeedsFilename } p := filepath.Join(conf.DownloadFolder, filename) _, err := os.Stat(p) pbase := p i := 1 for !errors.Is(err, syscall.ENOENT) { p = pbase + "-" + strconv.Itoa(i) _, err = os.Stat(p) i += 1 } return os.WriteFile(p, state.Body, 0o644) } func Mark(state *BrowserState, args []string, conf *Config) error { switch args[0] { case "add": return MarkAdd(state, conf, args[1], args[2]) case "go": return MarkGo(state, conf, args[1]) case "list": return MarkList(state) case "delete": return MarkDelete(state, args[1]) } return ErrInvalidMarkArgs } func TourCmd(state *BrowserState, args []string, conf *Config) error { switch args[0] { case "add": if args[1] == "next" { return TourAddNext(state, conf, args[2:]) } return TourAdd(state, conf, args[1:]) case "show": return TourShow(state) case "select": return TourSelect(state, args[1]) case "next": return TourNext(state, conf) case "previous": return TourPrevious(state, conf) case "clear": return TourClear(state) case "list": return TourList(state) case "go": return TourGo(state, conf, args[1]) } return ErrInvalidTourArgs } func Pipe(state *BrowserState, cmdStr string) error { if state.Body == nil { return ErrMustBeOnAPage } sh, err := exec.LookPath("sh") if err != nil { return err } cmd := exec.Command(sh, "-c", cmdStr) cmd.Stdin = bytes.NewBuffer(state.Body) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() }