Files
roro9stack/src/services/gemini_service.h
T
twislaandClaude Opus 5.5 7b5df713ad
CI / build (pull_request) Successful in 1m38s
Site / build (pull_request) Successful in 10s
Shell: only its own replies, Apps by their names, and rm as Unix has it (#67)
The Shell shows the replies to its own commands and nothing else. The
console knows who each line is printed for (Console::As, Console::origin):
a command run from the Shell prints as the Shell's, and what answers it
later from another task carries that along (ls, tasks, du, cp, update
check, sd list, screenshot, gemini get). Ctrl+b shows everything instead.
This replaces the ten-second window, which was a guess.

An App's name with a capital opens it (Notes, Irc, Wifi, Gnss, Gemini, Lora,
Storage, Shell, System, Settings), from the Shell and from the consoles.

rm needs -r for a folder, here and over the consoles. In the Shell a file,
or a folder with something in it, is asked about unless -f; an empty folder
with -r goes without a word.

The Shell now hands its line to the main loop to run: run from inside the
key handler, rm on a folder overflowed the loop's stack and crashed the
device. `info` says which App is in front.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 11:35:29 +02:00

150 lines
7.5 KiB
C++

#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <string>
#include <vector>
#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<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);
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<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