#pragma once #include #include #include #include #include "platform/console.h" #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 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 // What a page costs per line on top of its text: TextBuffer's index (8) and the App's tables // (type, first row, links: about 8). Counting only the first left the steady floor 1.5 to 3 KB // short on a windowed page (found in M3). static constexpr size_t kBytesPerLine = 16; 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 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 `: the same fetch, reported on the console instead of to the App. bool fetchToConsole(const std::string& url); Console::Origin consoleFrom_ = Console::Origin::System; // who asked for that fetch: its report is theirs 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 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