Subj : Gopher client To : All From : Shawn Highfield Date : Fri Jul 10 2026 09:48 am Here's a gopher client I had gemini write and claude fix. I take no credit for any of the code, it compiles and works pretty good for something a computer generated in about 30 mins. ----- gophernav.pas begins ----- program tiegopher; (* TieGopher July 10 2026 Freeware do what you want license. Written by Gemini fixed by Claude *) {$mode objfpc}{$H+} uses Crt, Classes, SysUtils, ssockets, Unix; type TMenuItem = record ItemType: Char; Desc: String; Selector: String; Host: String; Port: Word; IsNavigable: Boolean; end; THistoryItem = record Host: String; Port: Word; Selector: String; end; var CurrentHost: String = 'gopher.floodgap.com'; CurrentPort: Word = 70; CurrentSelector: String = ''; MenuItems: array of TMenuItem; NavMap: array of Integer; History: array of THistoryItem; // TUI State variables ActiveNavIndex: Integer; ScrollOffset: Integer; TargetMenu: TMenuItem; SearchQuery: String; // --------------------------------------------------------- // Fetches data from a Gopher server and returns a StringList // --------------------------------------------------------- function FetchData(Host: String; Port: Word; Selector: String): TStringList; var Stream: TInetSocket; MemStream: TMemoryStream; Buffer: array[0..4095] of Byte; BytesRead: Integer; Request: String; begin Result := TStringList.Create; MemStream := TMemoryStream.Create; try try Stream := TInetSocket.Create(Host, Port); try Request := Selector + #13#10; Stream.WriteBuffer(Request[1], Length(Request)); repeat BytesRead := Stream.Read(Buffer[0], SizeOf(Buffer)); if BytesRead > 0 then MemStream.Write(Buffer[0], BytesRead); until BytesRead <= 0; finally Stream.Free; end; MemStream.Position := 0; Result.LoadFromStream(MemStream); except on E: Exception do Result.Add('3Connection Error: ' + E.Message + #9 + 'fake' + #9 + 'error.host' + #9 + '1'); end; finally MemStream.Free; end; end; // --------------------------------------------------------- // Fetches raw bytes (for binary files) into a TMemoryStream // --------------------------------------------------------- function FetchRaw(Host: String; Port: Word; Selector: String): TMemoryStream; var Stream: TInetSocket; Buffer: array[0..4095] of Byte; BytesRead: Integer; Request: String; begin Result := TMemoryStream.Create; try Stream := TInetSocket.Create(Host, Port); try Request := Selector + #13#10; Stream.WriteBuffer(Request[1], Length(Request)); repeat BytesRead := Stream.Read(Buffer[0], SizeOf(Buffer)); if BytesRead > 0 then Result.Write(Buffer[0], BytesRead); until BytesRead <= 0; finally Stream.Free; end; Result.Position := 0; except on Exception do Result.Position := 0; { return whatever we have; caller checks Size } end; end; // --------------------------------------------------------- // Parses a raw tab-delimited Gopher line into a TMenuItem // --------------------------------------------------------- function ParseMenuLine(const Line: String): TMenuItem; var Parts: array of String; i: Integer; CurStr: String; begin Result.ItemType := 'i'; if Length(Line) = 0 then Exit; Result.ItemType := Line[1]; SetLength(Parts, 0); CurStr := ''; for i := 2 to Length(Line) do begin if Line[i] = #9 then begin SetLength(Parts, Length(Parts) + 1); Parts[High(Parts)] := CurStr; CurStr := ''; end else CurStr := CurStr + Line[i]; end; SetLength(Parts, Length(Parts) + 1); Parts[High(Parts)] := CurStr; Result.Desc := ''; Result.Selector := ''; Result.Host := ''; Result.Port := 70; if Length(Parts) > 0 then Result.Desc := Parts[0]; if Length(Parts) > 1 then Result.Selector := Parts[1]; if Length(Parts) > 2 then Result.Host := Parts[2]; if Length(Parts) > 3 then Result.Port := StrToIntDef(Trim(Parts[3]), 70); Result.IsNavigable := Result.ItemType in ['0', '1', '7', 'h', { text, menu, search, html-link } '4', '5', '6', '9', { encoded / binary files } 'g', 'I', 'p', { images } 's', 'd', ';']; { sound, document, video } end; // --------------------------------------------------------- // Downloads and displays a Text file (Type 0) // --------------------------------------------------------- procedure ShowText(Host: String; Port: Word; Selector: String); var Data: TStringList; i, LineCount: Integer; Cmd: String; begin ClrScr; WriteLn('Fetching text from ', Host, '...'); Data := FetchData(Host, Port, Selector); ClrScr; LineCount := 0; for i := 0 to Data.Count - 1 do begin if (Data[i] = '.') and (i = Data.Count - 1) then Break; WriteLn(Data[i]); Inc(LineCount); if LineCount mod (WindMaxY - 2) = 0 then begin TextColor(LightCyan); Write('-- Press Enter for more, or "q" to stop --'); NormVideo; ReadLn(Cmd); if LowerCase(Trim(Cmd)) = 'q' then Break; GotoXY(1, WhereY - 1); ClrEol; end; end; Data.Free; TextColor(LightCyan); WriteLn; WriteLn('-- End of file. Press Enter to return to menu --'); NormVideo; ReadLn; end; // --------------------------------------------------------- // Screen-width helper (columns in the current text window) // --------------------------------------------------------- function ScreenCols: Integer; begin ScreenCols := Lo(WindMax) + 1; end; // --------------------------------------------------------- // Picks a 7-char type tag so descriptions line up // --------------------------------------------------------- function RowTag(const Item: TMenuItem): String; begin case Item.ItemType of 'i': RowTag := ' '; '3': RowTag := ' [ERR] '; '0': RowTag := ' [TXT] '; '1': RowTag := ' [DIR] '; '7': RowTag := ' [FND] '; 'h': RowTag := ' [WWW] '; else if Item.IsNavigable then RowTag := ' [BIN] ' else RowTag := ' [?] '; end; end; // --------------------------------------------------------- // Clamps/pads a row to the screen width: stops long lines // from wrapping and lets the highlight span the whole row // --------------------------------------------------------- function RowText(const Prefix, Desc: String): String; var W: Integer; begin W := ScreenCols - 1; { leave the last column free to avoid auto-wrap } RowText := Prefix + Desc; if Length(RowText) > W then RowText := Copy(RowText, 1, W) else RowText := RowText + StringOfChar(' ', W - Length(RowText)); end; // --------------------------------------------------------- // Downloads a binary item to disk (images, sounds, etc.) // --------------------------------------------------------- procedure SaveFile(const Item: TMenuItem); var Data: TMemoryStream; HomeDir, FileName, BaseName, Answer: String; begin BaseName := ExtractFileName(Item.Selector); if Trim(BaseName) = '' then BaseName := 'gopherfile'; GotoXY(1, WindMaxY); ClrEol; TextColor(LightGreen); Write('Save as [', BaseName, ']: '); NormVideo; ReadLn(Answer); if Trim(Answer) <> '' then FileName := Trim(Answer) else FileName := BaseName; HomeDir := GetEnvironmentVariable('HOME'); if HomeDir <> '' then FileName := HomeDir + PathDelim + FileName; ClrScr; WriteLn('Downloading ', Item.Selector, ' from ', Item.Host, ' ...'); Data := FetchRaw(Item.Host, Item.Port, Item.Selector); try if Data.Size > 0 then begin try Data.SaveToFile(FileName); TextColor(LightGreen); WriteLn('Saved ', Data.Size, ' bytes to ', FileName); except on E: Exception do begin TextColor(LightRed); WriteLn('Error saving file: ', E.Message); end; end; end else begin TextColor(LightRed); WriteLn('No data received (empty file or connection error).'); end; NormVideo; finally Data.Free; end; TextColor(LightCyan); WriteLn('-- Press Enter to return to menu --'); NormVideo; ReadLn; end; // --------------------------------------------------------- // Shell-escapes a string for safe use inside single quotes // --------------------------------------------------------- function ShellSingleQuote(const S: String): String; const SQ = ''''; { one single-quote character } begin ShellSingleQuote := SQ + StringReplace(S, SQ, SQ + '\' + SQ + SQ, [rfReplaceAll]) + SQ; end; // --------------------------------------------------------- // Opens a Gopher 'h' (HTML) link in the system browser // --------------------------------------------------------- procedure OpenURL(const RawSelector: String); var URL: String; begin URL := RawSelector; { 'h' items almost always carry "URL:" in the selector. } if (Length(URL) >= 4) and (LowerCase(Copy(URL, 1, 4)) = 'url:') then URL := Copy(URL, 5, Length(URL) - 4); URL := Trim(URL); GotoXY(1, WindMaxY); ClrEol; if URL = '' then begin TextColor(LightRed); Write('This link has no URL to open.'); NormVideo; Delay(1200); Exit; end; { Launch detached and injection-safe: the URL is single-quoted with any embedded quotes escaped, output is discarded, and '&' backgrounds it so the browser opens without freezing the TUI or leaving a zombie. } FpSystem('xdg-open ' + ShellSingleQuote(URL) + ' >/dev/null 2>&1 &'); TextColor(LightGreen); Write(Copy('Opening in browser: ' + URL, 1, ScreenCols - 1)); NormVideo; Delay(1000); end; // --------------------------------------------------------- // Renders the viewport based on current scroll position // --------------------------------------------------------- procedure DrawMenu(ViewCapacity: Integer); var i, TargetMenuIdx: Integer; begin if Length(NavMap) > 0 then TargetMenuIdx := NavMap[ActiveNavIndex] else TargetMenuIdx := -1; // Auto-scroll the camera if the selection goes out of bounds if TargetMenuIdx <> -1 then begin if TargetMenuIdx < ScrollOffset then ScrollOffset := TargetMenuIdx; if TargetMenuIdx >= ScrollOffset + ViewCapacity then ScrollOffset := TargetMenuIdx - ViewCapacity + 1; end; ClrScr; TextColor(LightGreen); WriteLn('======================================================='); WriteLn(' Gopherspace: ', CurrentHost, ':', CurrentPort); WriteLn('======================================================='); NormVideo; // Render only the items that fit on screen for i := ScrollOffset to ScrollOffset + ViewCapacity - 1 do begin if i > High(MenuItems) then Break; // Apply Highlight if i = TargetMenuIdx then begin TextBackground(LightGray); TextColor(Black); end else begin TextBackground(Black); TextColor(LightGray); end; WriteLn(RowText(RowTag(MenuItems[i]), MenuItems[i].Desc)); NormVideo; end; // Footer GotoXY(1, WindMaxY); TextColor(LightGreen); Write(Copy('Arrows/PgUp/PgDn: Move | Enter: Select | G: Go | B: Back | Q: Quit', 1, ScreenCols - 1)); NormVideo; end; // --------------------------------------------------------- // Parses a gopher URL or bare host into a TMenuItem // gopher://host:port/Tselector (T = one item-type char) // --------------------------------------------------------- function ParseGopherURL(Raw: String): TMenuItem; var P: Integer; HostPort, PathPart: String; begin Result.ItemType := '1'; Result.Desc := ''; Result.Selector := ''; Result.Host := ''; Result.Port := 70; Result.IsNavigable := True; Raw := Trim(Raw); if Raw = '' then Exit; { Strip a leading gopher:// scheme if present. } if LowerCase(Copy(Raw, 1, 9)) = 'gopher://' then Delete(Raw, 1, 9); { Split host[:port] from the path at the first '/'. } P := Pos('/', Raw); if P > 0 then begin HostPort := Copy(Raw, 1, P - 1); PathPart := Copy(Raw, P + 1, Length(Raw)); end else begin HostPort := Raw; PathPart := ''; end; { Optional :port on the host. } P := Pos(':', HostPort); if P > 0 then begin Result.Host := Copy(HostPort, 1, P - 1); Result.Port := StrToIntDef(Copy(HostPort, P + 1, Length(HostPort)), 70); end else Result.Host := HostPort; { First path char is the gopher item type; the rest is the selector. } if PathPart <> '' then begin Result.ItemType := PathPart[1]; Result.Selector := Copy(PathPart, 2, Length(PathPart)); end; end; // --------------------------------------------------------- // Prompts for a URL/host, parses it, and acts on it the // same way Enter does. Returns True if a new menu should // be fetched by the main loop. // --------------------------------------------------------- function GoToAddress: Boolean; var Input, Query: String; Item: TMenuItem; procedure PushHistory; begin SetLength(History, Length(History) + 1); History[High(History)].Host := CurrentHost; History[High(History)].Port := CurrentPort; History[High(History)].Selector := CurrentSelector; end; begin Result := False; GotoXY(1, WindMaxY); ClrEol; TextColor(LightCyan); Write('Go to (host or gopher URL): '); NormVideo; ReadLn(Input); Input := Trim(Input); if Input = '' then Exit; Item := ParseGopherURL(Input); if Item.Host = '' then Exit; case Item.ItemType of '0': ShowText(Item.Host, Item.Port, Item.Selector); 'h': OpenURL(Item.Selector); '7': begin GotoXY(1, WindMaxY); ClrEol; TextColor(LightGreen); Write('Enter search query: '); NormVideo; ReadLn(Query); if Trim(Query) <> '' then begin PushHistory; CurrentHost := Item.Host; CurrentPort := Item.Port; CurrentSelector := Item.Selector + #9 + Trim(Query); Result := True; end; end; '4', '5', '6', '9', 'g', 'I', 'p', 's', 'd', ';': SaveFile(Item); else begin PushHistory; CurrentHost := Item.Host; CurrentPort := Item.Port; CurrentSelector := Item.Selector; Result := True; end; end; end; // --------------------------------------------------------- // Main execution loop // --------------------------------------------------------- var MenuData: TStringList; i, ViewCapacity: Integer; NeedFetch: Boolean; Key: Char; begin NeedFetch := True; History := nil; while True do begin // Fetch data only when we've navigated to a new menu if NeedFetch then begin ClrScr; WriteLn('Fetching menu from ', CurrentHost, '...'); MenuData := FetchData(CurrentHost, CurrentPort, CurrentSelector); SetLength(MenuItems, 0); SetLength(NavMap, 0); for i := 0 to MenuData.Count - 1 do begin if (MenuData[i] = '.') or (MenuData[i] = '') then Continue; SetLength(MenuItems, Length(MenuItems) + 1); MenuItems[High(MenuItems)] := ParseMenuLine(MenuData[i]); if MenuItems[High(MenuItems)].IsNavigable then begin SetLength(NavMap, Length(NavMap) + 1); NavMap[High(NavMap)] := High(MenuItems); end; end; MenuData.Free; ActiveNavIndex := 0; ScrollOffset := 0; NeedFetch := False; end; // ViewCapacity is screen height minus header(3 lines) and footer(1 line) ViewCapacity := WindMaxY - 4; DrawMenu(ViewCapacity); // Input Handling Key := ReadKey; if Key = #0 then // Extended keys (Arrows) begin Key := ReadKey; if Length(NavMap) = 0 then Continue; // Ignore arrows if nothing is selectable case Key of #72: if ActiveNavIndex > 0 then Dec(ActiveNavIndex); // Up Arrow #80: if ActiveNavIndex < High(NavMap) then Inc(ActiveNavIndex); // Down Arrow #73: begin // Page Up ActiveNavIndex := ActiveNavIndex - ViewCapacity; if ActiveNavIndex < 0 then ActiveNavIndex := 0; end; #81: begin // Page Down ActiveNavIndex := ActiveNavIndex + ViewCapacity; if ActiveNavIndex > High(NavMap) then ActiveNavIndex := High(NavMap); end; end; end else begin case LowerCase(Key) of 'q': Break; 'b': begin if Length(History) > 0 then begin CurrentHost := History[High(History)].Host; CurrentPort := History[High(History)].Port; CurrentSelector := History[High(History)].Selector; SetLength(History, Length(History) - 1); NeedFetch := True; end; end; 'g': if GoToAddress then NeedFetch := True; #13: begin // Enter Key if Length(NavMap) > 0 then begin TargetMenu := MenuItems[NavMap[ActiveNavIndex]]; if TargetMenu.ItemType = '1' then begin SetLength(History, Length(History) + 1); History[High(History)].Host := CurrentHost; History[High(History)].Port := CurrentPort; History[High(History)].Selector := CurrentSelector; CurrentHost := TargetMenu.Host; CurrentPort := TargetMenu.Port; CurrentSelector := TargetMenu.Selector; NeedFetch := True; end else if TargetMenu.ItemType = '0' then begin ShowText(TargetMenu.Host, TargetMenu.Port, TargetMenu.Selector); end else if TargetMenu.ItemType = '7' then begin // Briefly take over the bottom row for standard text input GotoXY(1, WindMaxY); ClrEol; TextColor(LightGreen); Write('Enter search query: '); NormVideo; ReadLn(SearchQuery); if Trim(SearchQuery) <> '' then begin SetLength(History, Length(History) + 1); History[High(History)].Host := CurrentHost; History[High(History)].Port := CurrentPort; History[High(History)].Selector := CurrentSelector; CurrentHost := TargetMenu.Host; CurrentPort := TargetMenu.Port; CurrentSelector := TargetMenu.Selector + #9 + SearchQuery; NeedFetch := True; end; end else if TargetMenu.ItemType = 'h' then OpenURL(TargetMenu.Selector) else SaveFile(TargetMenu); end; end; end; end; end; end. ----- gophernav.pas ends ----- --- msged/lnx 6.3 archlinux-git * Origin: 1:229/452 (1:229/452@fidonet) .