Public Access
Found in M3 while checking floors with the radio on: a windowed page left 1.5 to 3 KB less than the 40 KB steady floor (Q86), radio or not. The budget counted TextBuffer's index (8 B/line) but not the App's tables, which also grew by doubling. Now 16 B/line, and the App sizes them exactly. The FAQ (1051 lines, windowed): 38.5 -> 39.6 KB after, radio asleep; 37.1 -> 39.0 KB with the radio listening. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
148 lines
7.4 KiB
C++
148 lines
7.4 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
|
|
// 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<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
|