Public Access
A page larger than memory allows is now windowed instead of cut: one pass over its file (cache or Saved Page) counts lines, indexes every 64th (offset, and the preformatted state there in bit 31: about 1 KB for a 1 MB page) and loads the first window. Scrolling near either end reads the next or previous window on the Gemini task; the line on top of the screen stays put, the scrollbar follows the whole page, and Tab at a window's edge pages on instead of wrapping. Window budgets count the memory the old window gives back. Display pages alternate between two cache files so the one on screen is never overwritten by the next fetch; jobs use a third. On the device, Cosmos with IRC connected: 226 of 419 lines at first, then lines 192-419, back to 64 and 0 while scrolling. `key space` added to the console's key command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
144 lines
7.1 KiB
C++
144 lines
7.1 KiB
C++
#pragma once
|
|
|
|
#include <freertos/FreeRTOS.h>
|
|
#include <freertos/semphr.h>
|
|
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "event_bus.h"
|
|
#include "gemini_response.h"
|
|
#include "key_value_store.h"
|
|
#include "services/clock_service.h"
|
|
#include "services/storage_service.h"
|
|
#include "text_buffer.h"
|
|
|
|
class NetworkClientSecure;
|
|
|
|
namespace roro {
|
|
|
|
// What a fetch came back with.
|
|
struct GeminiPage {
|
|
enum class Outcome { Ok, Failed, CertificateChanged };
|
|
Outcome outcome = Outcome::Failed;
|
|
std::string requested; // what was asked for
|
|
std::string url; // where it ended up, after redirects (file://… for a Saved Page)
|
|
gemini::Header header; // the final response's header
|
|
gemini::TextBuffer text; // 2x only: the body as lines, up to kMaxBody
|
|
bool truncated = false; // the body was cut: see truncatedWhy
|
|
std::string truncatedWhy;
|
|
std::string error; // Failed: why, for a human
|
|
std::string host; // CertificateChanged: whose, and both fingerprints
|
|
int port = 1965;
|
|
std::string pinned, presented;
|
|
std::string cachePath; // with a card: the whole body as received (Saved Pages copy it)
|
|
size_t receivedBytes = 0; // the whole body, which may be more than `text` holds
|
|
std::string savedFrom; // a Saved Page: the URL it was saved from (links resolve against it)
|
|
std::string savedOn; // and when, "2026-10-05 01:20"
|
|
uint32_t ms = 0;
|
|
// A page bigger than memory allows is read from its file as you scroll: `text` then holds
|
|
// lines [windowStart, windowStart + text.lineCount()) of totalLines. lineIndex has where every
|
|
// 64th line starts in the file (bit 31: inside a preformatted block there).
|
|
std::string backing; // the file the page is in (a cache file or a Saved Page)
|
|
bool windowed = false;
|
|
size_t totalLines = 0, windowStart = 0;
|
|
std::vector<uint32_t> lineIndex;
|
|
std::string windowOf; // a window result: the backing file it belongs to
|
|
|
|
// What relative links resolve against.
|
|
const std::string& base() const { return savedFrom.empty() ? url : savedFrom; }
|
|
};
|
|
|
|
// Gemini (docs/milestones/G1.md). Everything that touches the network or the SD card runs as one
|
|
// job at a time on a short-lived task of its own: a TLS handshake or a missing card can block for
|
|
// seconds, which the main loop's watchdog wouldn't allow, and an idle client should cost no stack.
|
|
// Certificates are trusted on first use, per host and port (Q71).
|
|
class GeminiService {
|
|
public:
|
|
static constexpr size_t kMaxBody = 64 * 1024; // Q74
|
|
static constexpr size_t kStartFloor = 55 * 1024; // Q86: no fetch below this
|
|
static constexpr size_t kSteadyFloor = 40 * 1024; // Q86: what must be left once the page is in
|
|
static constexpr size_t kTransientFloor = 20 * 1024; // Q86: while the TLS connection is open
|
|
static constexpr int kMaxRedirects = 5; // Q72
|
|
static constexpr size_t kMaxOnCard = 1024 * 1024; // a text page larger than this is cut
|
|
static constexpr int kMaxLinkedPages = 30; // Q83
|
|
static constexpr size_t kIndexEvery = 64; // lines between lineIndex entries
|
|
static constexpr size_t kMaxLineBytes = 8192; // longer lines are cut
|
|
static constexpr const char* kBookmarks = "/gemini/bookmarks.gmi";
|
|
static constexpr const char* kStartUrl = "about:start";
|
|
|
|
GeminiService(KeyValueStore& store, StorageService& storage, ClockService& clock, EventBus& bus);
|
|
|
|
// Pages: gemini://…, about:start (bookmarks, Saved Pages, defaults) or file:///gemini/saved/….
|
|
// `preferSaved`: open the Saved Page of that URL if there is one (inside a Saved Page, Q84).
|
|
bool fetch(const std::string& url, bool preferSaved = false);
|
|
// Actions; each reports one line through takeMessage().
|
|
bool save(const std::string& url); // s (Q82)
|
|
bool saveWithLinks(const std::string& url, std::vector<std::string> links); // S (Q83)
|
|
bool refresh(const std::string& savedPath); // r on a Saved Page
|
|
bool deleteSaved(const std::string& savedPath); // d on a Saved Page (Q85)
|
|
bool addBookmark(const std::string& url, const std::string& title); // b (Q77)
|
|
bool download(const std::string& url); // s on a non-text page (Q73)
|
|
// The window of a big page starting at `firstLine` (a multiple of kIndexEvery), from its file.
|
|
bool loadWindow(const GeminiPage& page, size_t firstLine);
|
|
|
|
bool busy() const { return busy_; }
|
|
bool takeResult(GeminiPage& out);
|
|
bool takeMessage(std::string& out);
|
|
void cancel() { cancel_ = true; }
|
|
// After the user accepts a changed certificate.
|
|
void trust(const std::string& host, int port, const std::string& fingerprint);
|
|
|
|
// `gemini get <url>`: the same fetch, reported on the console instead of to the App.
|
|
bool fetchToConsole(const std::string& url);
|
|
|
|
private:
|
|
enum class Job { Fetch, Save, SaveWithLinks, Refresh, Delete, Bookmark, Download, Window };
|
|
// Cache files: two take turns for pages on screen (the one shown is never overwritten by the
|
|
// next), one for jobs (saving linked pages, refreshing).
|
|
static constexpr int kWorkSlot = 2;
|
|
static const char* slotPath(int slot);
|
|
|
|
bool start(Job job);
|
|
static void taskEntry(void* self);
|
|
void run();
|
|
void runFetch(GeminiPage& page);
|
|
void fetchFollowing(const std::string& url, GeminiPage& page, bool loadIntoRam, int slot);
|
|
void fetchOne(const std::string& url, GeminiPage& page, bool loadIntoRam, int slot);
|
|
bool receiveToCard(::NetworkClientSecure& tls, const char* first, size_t len, GeminiPage& page, int slot);
|
|
void loadFromCard(GeminiPage& page, size_t freeBefore, const std::string& path);
|
|
void readWindow(GeminiPage& page, size_t firstLine, size_t budget);
|
|
void openSaved(const std::string& path, GeminiPage& page);
|
|
std::string savedFirstLine(const std::string& path);
|
|
std::string savedFrom(const std::string& path);
|
|
void composeStart(GeminiPage& page);
|
|
std::string saveCached(const std::string& url); // "" or what went wrong
|
|
const char* cacheOf(const std::string& url) const; // the cache file holding url's body, or nullptr
|
|
void finishWithMessage(const std::string& text);
|
|
void notify(const std::string& text);
|
|
void report(const GeminiPage& page, size_t heapBefore, size_t heapLowest);
|
|
std::string pinKey(const std::string& host, int port) const;
|
|
std::string now() const;
|
|
|
|
KeyValueStore& store_;
|
|
StorageService& storage_;
|
|
ClockService& clock_;
|
|
EventBus& bus_;
|
|
SemaphoreHandle_t lock_;
|
|
Job job_ = Job::Fetch;
|
|
std::string arg_, arg2_;
|
|
std::vector<std::string> links_;
|
|
bool preferSaved_ = false;
|
|
GeminiPage result_;
|
|
std::string message_;
|
|
std::string slotUrl_[3]; // the URL whose body each cache file holds
|
|
int nextDisplay_ = 0;
|
|
size_t windowLine_ = 0, windowReleasing_ = 0;
|
|
GeminiPage windowPage_;
|
|
bool replaced_ = false; // the last save replaced an older copy
|
|
volatile bool busy_ = false, ready_ = false, messageReady_ = false, cancel_ = false, toConsole_ = false;
|
|
size_t lowest_ = 0;
|
|
};
|
|
|
|
} // namespace roro
|