Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a2f46b428 | ||
|
|
dafbdaddf6 | ||
|
|
fe886e2c1d | ||
|
|
9ca9a16de8 | ||
|
|
fd306d5013 | ||
|
|
10f3ff7a4c | ||
|
|
7b8d9391a4 | ||
|
|
babb1d114e | ||
|
|
2d384f5cff | ||
|
|
dc9b0e14ce |
@@ -40,6 +40,14 @@ _Avoid_: DM (in docs), private message
|
||||
Rebroadcasting another Node's packet so it travels further across the mesh.
|
||||
_Avoid_: forwarding, repeating
|
||||
|
||||
**Capsule**:
|
||||
A Gemini site: everything served by one host on Geminispace.
|
||||
_Avoid_: site, server (when the content is meant)
|
||||
|
||||
**Saved Page**:
|
||||
A Gemini page kept on the SD card to read offline, with the URL it came from and when it was saved. Kept until the user deletes it; Storage Clean-up never offers it.
|
||||
_Avoid_: cache, download (a download is a non-text file saved from Gemini)
|
||||
|
||||
**GNSS Service**:
|
||||
The Service that owns the GNSS receiver on the Cap: it reads its NMEA sentences in the background and holds the current Fix, position, time and satellites.
|
||||
_Avoid_: GPS (GPS is one constellation among several)
|
||||
|
||||
@@ -54,6 +54,12 @@ To install from the SD card instead, copy the `.ota` file from `.pio/build/cardp
|
||||
|
||||
**The private key** lives in `~/.config/roro9stack/ota-key.pem` and must never be committed. If it's lost, generate a new pair and flash once over USB.
|
||||
|
||||
## Gemini
|
||||
|
||||
The Gemini App browses Geminispace (docs/milestones/G1.md): Tab and Shift+Tab pick a link, Enter follows it, Back returns (to where the page was scrolled), Space pages down, `g` types an address. Certificates are trusted on first use; a changed one stops the page and asks.
|
||||
|
||||
On a page, `b` bookmarks it, `s` saves it to the SD card to read offline (a non-text file goes to `/gemini/downloads/`), `S` saves it with the pages it links to on the same capsule (up to 30). The start page lists bookmarks and Saved Pages; inside a Saved Page, `r` refreshes it and `d` deletes it. With a card, every page streams through `/gemini/cache/` so a large one arrives whole even with IRC connected; what doesn't fit in memory stays on the card.
|
||||
|
||||
## Development aids
|
||||
|
||||
`scripts/serial_log.sh [seconds] [command…]` records the serial output, and can send commands to the firmware first. For example, `scripts/serial_log.sh 30 short sleep:12 burst` sets short screen timeouts, waits 12 s, then sends a burst of Toasts.
|
||||
@@ -70,6 +76,8 @@ To install from the SD card instead, copy the `.ota` file from `.pio/build/cardp
|
||||
| `cat <path>` | Prints the first ~1.2 KB of a file on the SD card |
|
||||
| `irc start` | Starts the IRC Service (normally done by opening the IRC App) |
|
||||
| `irc stop` | Stops it, as `/quit` does: QUIT if connected, no more retries, and the App stays disconnected until you type |
|
||||
| `gemini get <url>` | Fetches a Gemini page and prints its header, size, certificate fingerprint and heap use |
|
||||
| `gemini trust <host> <port> <sha256>` | Pins a certificate by hand (the Gemini App asks when one changes) |
|
||||
| `irc say <buffer> <text>` | Types into a Buffer, commands included (`irc say 0 /join #test`) |
|
||||
| `irc dump` | Prints IRC status, memory, and the last lines of each Buffer |
|
||||
| `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap |
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# G1 — Gemini client
|
||||
|
||||
**Status:** done, tagged v0.5.0. Every step was checked on the device; reading Saved Pages with Wi-Fi off was checked by hand (2026-10-05).
|
||||
|
||||
**Goal:** browse Geminispace from the Cardputer: fetch and read gemtext over TLS, follow links, answer input prompts, keep bookmarks, and save pages to the SD card to read later, offline. A side milestone between M2 and M3, tagged v0.5.0 when done.
|
||||
|
||||
Gemini (geminiprotocol.net): one request per TLS connection on port 1965, the request is the URL and CRLF, the response a `<status> <meta>` header line, then the body. Most capsules use self-signed certificates: trust on first use is the norm.
|
||||
|
||||
## Decisions (design round 2026-10-05)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q70 | A milestone of its own, **G1**, before M3: plan, tests first, measured on the device, tagged v0.5.0. |
|
||||
| Q71 | **TOFU:** the first certificate seen for a host is pinned (SHA-256, in NVS). If it changes, the page isn't shown; a dialog shows both fingerprints and asks whether to trust the new one. Self-signed or expired certificates are fine; only a change counts. |
|
||||
| Q72 | Responses: 1x input (11 hidden, for passwords), 2x content, up to 5 redirects (3x), 4x/5x errors with the server's message. 6x (client certificates): "not supported". |
|
||||
| Q73 | `text/gemini` is rendered, other `text/*` shown as plain text. Anything else can be saved to `/gemini/downloads/`, not shown. |
|
||||
| Q74 | Up to **64 KB on screen**, larger pages truncated with a notice. Saving streams to the card, so a larger page is saved whole. |
|
||||
| Q75 | Gemtext rendering: text wrapped to the 40-column screen; `#`/`##`/`###` headings in bold and accent; `*` lists with bullets; `>` quotes indented and muted; preformatted blocks unwrapped, Left/Right to scroll; `=>` links with their label, numbered. |
|
||||
| Q76 | Up/Down scroll; Tab and Shift+Tab move between links; Enter follows; Backspace goes back; `g` opens the address line. Links to other protocols show their URL and aren't followed. |
|
||||
| Q77 | Back history of 20 URLs in RAM, with scroll positions; going back refetches (or reopens a Saved Page). **Bookmarks** in `/gemini/bookmarks.gmi` (a gemtext page, shown on the start page); `b` adds the current page. Without a card, a built-in start page. |
|
||||
| Q78 | Start page: bookmarks, then Saved Pages, then defaults: geminiprotocol.net, a search engine (kennedy.gemi.dev), an aggregator (Cosmos; Antenna was down when measured). |
|
||||
| Q79 | UTF-8 decoded; characters outside the Latin-1 fonts shown as `?`. |
|
||||
| Q80 | IRC and Gemini can run together: each fetch opens one connection, reads and closes it. If there isn't memory for a second TLS connection, the fetch fails with a clear message and IRC is untouched. Measured in step 1. |
|
||||
| Q81 | Debug aid: `gemini get <url>` prints the status, MIME type, size, certificate fingerprint and the first lines. URL resolution (RFC 3986), the response header and gemtext parsing are host-tested. |
|
||||
| Q82 | `s` saves the page on screen as a **Saved Page**: `/gemini/saved/<host>/<path>.gmi`, the gemtext as received plus a first line with its URL and save date. Saving again replaces it, and says so. |
|
||||
| Q83 | `S` saves the page and the pages it links to, one level deep: gemtext only, same host only, at most 30 pages, in the background with a progress Toast. |
|
||||
| Q84 | The start page lists Saved Pages, newest first, grouped by capsule; they open with no network. In a Saved Page, a link to another Saved Page opens the saved copy; other links fetch online if Wi-Fi is up, or say "not saved, offline". A Saved Page shows when it was saved; `r` refreshes it. |
|
||||
| Q86 | *Decided after step 1, revised after Q88.* **Two floors:** free heap stays above 40 KB in steady state; a fetch refuses to start below **55 KB** free ("not enough memory: stop IRC or retry"). The firmware's own allocations during a fetch (a page in RAM, a window) keep 20 KB free. With IRC connected, the TLS connection itself can briefly take the heap lower, depending on the server's record sizes: measured 24, 19.5, 15.5 and **13 KB**. *Accepted:* about 12 KB for a moment during a fetch with IRC up, rather than refusing most fetches (a 70 KB start floor) or dropping IRC's connection for each page. |
|
||||
| Q87 | *Decided in step 3.* **With a card, every page streams to `/gemini/cache/page.gmi`** in 1 KB pieces while its TLS connection is open; once the connection closes and its ~45 KB is back, the page is loaded into RAM as far as the 40 KB floor allows. The whole page stays on the card (Saved Pages copy it). Without a card, the page goes straight to RAM under the same two floors. Pages are held as lines in 4 KB chunks, never one large block (the largest free block with IRC connected is about 31 KB). |
|
||||
| Q88 | *Added after step 6.* **A page bigger than memory allows is read from the card as you scroll.** Opening it, one pass over its file counts the lines, records where every 64th starts (and whether it's inside a preformatted block), and loads the first window. Scrolling near either end of the window reads the next or previous one in the background, keeping the line on top of the screen where it is; the scrollbar follows the whole page. Display pages alternate between two cache files, so the one on screen is never overwritten by the next fetch; background jobs use a third. |
|
||||
| Q85 | Saved Pages are deleted from the App only (`d`, with confirmation), never by Storage Clean-up's age rules, like Notes. |
|
||||
|
||||
## Measured (step 1)
|
||||
|
||||
- `gemini://geminiprotocol.net/`: `20 text/gemini`, 1,184 bytes, TLS handshake 0.7–1.1 s, whole fetch 0.7–1.1 s; kennedy.gemi.dev 1.9 s. The fetch task's stack peaks at about 3.6 KB of 6.
|
||||
- **Heap, Debug Build, IRC connected over TLS:** about 68 KB free before a fetch. After the handshake the fetch holds about 32 KB (36–40 KB left); the handshake itself (certificate chain parsed with the 16 KB receive buffer allocated) dips to about **24 KB** for a second or two. Nothing leaks: the heap after matches the heap before.
|
||||
- **Step 3, Cosmos (31.6 KB) with IRC connected:** first stopped at 4.6 KB (RAM only, the transfer's 20 KB floor). Streamed to the card: the whole page on the card, 20 KB of it loaded, lowest free heap 19.5 KB during the transfer and 43 KB once loaded. Without IRC: the whole page in RAM. Redirects (Cosmos `31`), input (`10`), not found (`51`) and a changed certificate (refused, both fingerprints shown) all checked on the device.
|
||||
- **Steps 4–6 on the device:** Project Gemini and its relative links, Back with the scroll restored, a refused YouTube link; `b` bookmarks, `s` saves (and says when it replaced an older copy), `S` saved 6 of 6 pages, the start page lists both; a Saved Page opens from the card with its origin, its saved links open saved copies, `r` refreshes, `d` asks first; Kennedy's input prompt sent "cardputer" and got 87 results; emoji drawn as `?`.
|
||||
- **A bug found there:** refreshing first loaded the whole Saved Page into RAM just to read its origin, next to the App's copy and a TLS connection: the heap fell to 436 bytes. Now only the first line is read, and every fetch (pages, saves, refreshes) checks the 55 KB start floor. Lowest since boot afterwards: 53.8 KB.
|
||||
- **Windowed pages (Q88), Cosmos with IRC connected:** 226 of 419 lines in memory at first; paging down loaded lines 192–419 in one window, scrolling back up loaded 64 onwards, then 0 onwards. Window budgets count the memory the old window gives back.
|
||||
- **Heap during a fetch with IRC connected:** lowest 13–15.5 KB in later runs (24 and 19.5 KB earlier), the TLS receive buffers varying with the server's records. Accepted (Q86, revised).
|
||||
- Antenna (`warmedal.se`) doesn't answer, from the PC either; the default aggregator becomes Cosmos (`gemini://skyjake.fi/~Cosmos/`, which redirects to `cosmos.skyjake.fi`).
|
||||
|
||||
## Done when
|
||||
|
||||
- `gemini get gemini://geminiprotocol.net/` prints the header, size and fingerprint on the console.
|
||||
- The Gemini App opens the start page, follows links (relative ones included), goes back, and follows redirects.
|
||||
- An input prompt (e.g. a search) takes a query and shows the results.
|
||||
- A changed certificate stops the page and asks.
|
||||
- `s` saves a page, `S` a page and its links; with Wi-Fi off, Saved Pages open and their saved links work.
|
||||
- Bookmarks are added with `b` and listed on the start page.
|
||||
- With IRC connected over TLS, a fetch still works, and the free heap stays above 40 KB.
|
||||
|
||||
## Work breakdown
|
||||
|
||||
1. **Two TLS connections:** measure the heap with IRC connected while a Gemini fetch runs.
|
||||
2. **Parsers** (host-tested): URL parsing and relative resolution, the response header, gemtext lines.
|
||||
3. **Fetch** on its own task, with TOFU and `gemini get`.
|
||||
4. **Gemini App:** rendering, scrolling, links, history, the address line.
|
||||
5. **Input prompts, redirects, bookmarks, downloads.**
|
||||
6. **Saved Pages,** then saving with linked pages.
|
||||
@@ -0,0 +1,59 @@
|
||||
#include "gemini_response.h"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
namespace roro::gemini {
|
||||
|
||||
namespace {
|
||||
std::string trim(const std::string& s) {
|
||||
size_t a = s.find_first_not_of(" \t"), b = s.find_last_not_of(" \t");
|
||||
return a == std::string::npos ? "" : s.substr(a, b - a + 1);
|
||||
}
|
||||
std::string lower(std::string s) {
|
||||
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
return s;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool parseHeader(const std::string& line, Header& out) {
|
||||
if (line.size() < 2 || !std::isdigit(static_cast<unsigned char>(line[0])) ||
|
||||
!std::isdigit(static_cast<unsigned char>(line[1])))
|
||||
return false;
|
||||
if (line.size() > 2 && line[2] != ' ') return false;
|
||||
std::string meta = line.size() > 3 ? line.substr(3) : "";
|
||||
if (meta.size() > 1024) return false;
|
||||
out.status = (line[0] - '0') * 10 + (line[1] - '0');
|
||||
out.meta = meta;
|
||||
return true;
|
||||
}
|
||||
|
||||
Category Header::category() const {
|
||||
switch (status / 10) {
|
||||
case 1: return Category::Input;
|
||||
case 2: return Category::Success;
|
||||
case 3: return Category::Redirect;
|
||||
case 4: return Category::TemporaryFailure;
|
||||
case 5: return Category::PermanentFailure;
|
||||
case 6: return Category::ClientCertificate;
|
||||
default: return Category::Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
std::string Header::mimeType() const {
|
||||
std::string type = lower(trim(meta.substr(0, meta.find(';'))));
|
||||
return type.empty() ? "text/gemini" : type;
|
||||
}
|
||||
|
||||
std::string Header::parameter(const std::string& name) const {
|
||||
size_t pos = meta.find(';');
|
||||
while (pos != std::string::npos) {
|
||||
size_t next = meta.find(';', pos + 1);
|
||||
std::string item = trim(meta.substr(pos + 1, next == std::string::npos ? std::string::npos : next - pos - 1));
|
||||
size_t eq = item.find('=');
|
||||
if (eq != std::string::npos && lower(trim(item.substr(0, eq))) == lower(name)) return trim(item.substr(eq + 1));
|
||||
pos = next;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
} // namespace roro::gemini
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace roro::gemini {
|
||||
|
||||
enum class Category { Input, Success, Redirect, TemporaryFailure, PermanentFailure, ClientCertificate, Unknown };
|
||||
|
||||
// The response header: "<two digits> <meta>", at most 1024 bytes of meta.
|
||||
struct Header {
|
||||
int status = 0;
|
||||
std::string meta;
|
||||
|
||||
Category category() const;
|
||||
bool sensitiveInput() const { return status == 11; }
|
||||
// 2x: the MIME type, lower-cased, without parameters ("text/gemini" when meta is empty).
|
||||
std::string mimeType() const;
|
||||
std::string parameter(const std::string& name) const; // e.g. "charset"
|
||||
};
|
||||
|
||||
bool parseHeader(const std::string& line, Header& out);
|
||||
|
||||
} // namespace roro::gemini
|
||||
@@ -0,0 +1,215 @@
|
||||
#include "gemini_url.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <vector>
|
||||
|
||||
#include "storage_paths.h"
|
||||
|
||||
namespace roro::gemini {
|
||||
|
||||
namespace {
|
||||
|
||||
// RFC 3986, appendix B: ^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?
|
||||
struct Parts {
|
||||
std::string scheme, authority, path, query, fragment;
|
||||
bool hasScheme = false, hasAuthority = false, hasQuery = false, hasFragment = false;
|
||||
};
|
||||
|
||||
Parts split(const std::string& s) {
|
||||
Parts p;
|
||||
size_t i = 0;
|
||||
size_t colon = s.find(':');
|
||||
size_t stop = s.find_first_of("/?#");
|
||||
if (colon != std::string::npos && colon > 0 && (stop == std::string::npos || colon < stop)) {
|
||||
p.scheme = s.substr(0, colon);
|
||||
p.hasScheme = true;
|
||||
i = colon + 1;
|
||||
}
|
||||
if (s.compare(i, 2, "//") == 0) {
|
||||
size_t end = s.find_first_of("/?#", i + 2);
|
||||
if (end == std::string::npos) end = s.size();
|
||||
p.authority = s.substr(i + 2, end - i - 2);
|
||||
p.hasAuthority = true;
|
||||
i = end;
|
||||
}
|
||||
size_t end = s.find_first_of("?#", i);
|
||||
if (end == std::string::npos) end = s.size();
|
||||
p.path = s.substr(i, end - i);
|
||||
i = end;
|
||||
if (i < s.size() && s[i] == '?') {
|
||||
end = s.find('#', i);
|
||||
if (end == std::string::npos) end = s.size();
|
||||
p.query = s.substr(i + 1, end - i - 1);
|
||||
p.hasQuery = true;
|
||||
i = end;
|
||||
}
|
||||
if (i < s.size() && s[i] == '#') {
|
||||
p.fragment = s.substr(i + 1);
|
||||
p.hasFragment = true;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
std::string lower(std::string s) {
|
||||
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
return s;
|
||||
}
|
||||
|
||||
// RFC 3986, section 5.2.4.
|
||||
std::string removeDotSegments(std::string in) {
|
||||
std::string out;
|
||||
while (!in.empty()) {
|
||||
if (in.compare(0, 3, "../") == 0) in.erase(0, 3);
|
||||
else if (in.compare(0, 2, "./") == 0) in.erase(0, 2);
|
||||
else if (in.compare(0, 3, "/./") == 0) in.replace(0, 3, "/");
|
||||
else if (in == "/.") in = "/";
|
||||
else if (in.compare(0, 4, "/../") == 0 || in == "/..") {
|
||||
in = in == "/.." ? "/" : in.substr(3);
|
||||
size_t slash = out.rfind('/');
|
||||
out.erase(slash == std::string::npos ? 0 : slash);
|
||||
} else if (in == "." || in == "..") in.clear();
|
||||
else {
|
||||
size_t next = in.find('/', in[0] == '/' ? 1 : 0);
|
||||
if (next == std::string::npos) next = in.size();
|
||||
out += in.substr(0, next);
|
||||
in.erase(0, next);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// RFC 3986, section 5.2.3.
|
||||
std::string merge(const Parts& base, const std::string& refPath) {
|
||||
if (base.hasAuthority && base.path.empty()) return "/" + refPath;
|
||||
size_t slash = base.path.rfind('/');
|
||||
return slash == std::string::npos ? refPath : base.path.substr(0, slash + 1) + refPath;
|
||||
}
|
||||
|
||||
// RFC 3986, section 5.3.
|
||||
std::string recompose(const Parts& p) {
|
||||
std::string out;
|
||||
if (p.hasScheme) out += p.scheme + ":";
|
||||
if (p.hasAuthority) out += "//" + p.authority;
|
||||
out += p.path;
|
||||
if (p.hasQuery) out += "?" + p.query;
|
||||
if (p.hasFragment) out += "#" + p.fragment;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool parseUrl(const std::string& text, Url& out) {
|
||||
Parts p = split(text);
|
||||
if (!p.hasScheme || !p.hasAuthority) return false;
|
||||
out = Url();
|
||||
out.scheme = lower(p.scheme);
|
||||
out.authority = p.authority;
|
||||
std::string hostPort = p.authority.substr(p.authority.find('@') == std::string::npos ? 0 : p.authority.find('@') + 1);
|
||||
size_t colon = hostPort.rfind(':');
|
||||
if (colon != std::string::npos && hostPort.find(']', colon) == std::string::npos) {
|
||||
out.port = std::atoi(hostPort.c_str() + colon + 1);
|
||||
hostPort = hostPort.substr(0, colon);
|
||||
}
|
||||
out.host = lower(hostPort);
|
||||
if (out.host.empty()) return false;
|
||||
out.path = p.path;
|
||||
out.query = p.query;
|
||||
out.fragment = p.fragment;
|
||||
out.hasAuthority = true;
|
||||
out.hasQuery = p.hasQuery;
|
||||
out.hasFragment = p.hasFragment;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isGemini(const std::string& url) {
|
||||
Parts p = split(url);
|
||||
return p.hasScheme && lower(p.scheme) == "gemini";
|
||||
}
|
||||
|
||||
std::string resolve(const std::string& baseText, const std::string& refText) {
|
||||
Parts base = split(baseText), r = split(refText), t;
|
||||
if (r.hasScheme) {
|
||||
t = r;
|
||||
t.path = removeDotSegments(r.path);
|
||||
} else {
|
||||
if (r.hasAuthority) {
|
||||
t.authority = r.authority;
|
||||
t.hasAuthority = true;
|
||||
t.path = removeDotSegments(r.path);
|
||||
t.query = r.query;
|
||||
t.hasQuery = r.hasQuery;
|
||||
} else {
|
||||
if (r.path.empty()) {
|
||||
t.path = base.path;
|
||||
t.query = r.hasQuery ? r.query : base.query;
|
||||
t.hasQuery = r.hasQuery || base.hasQuery;
|
||||
} else {
|
||||
t.path = removeDotSegments(r.path[0] == '/' ? r.path : merge(base, r.path));
|
||||
t.query = r.query;
|
||||
t.hasQuery = r.hasQuery;
|
||||
}
|
||||
t.authority = base.authority;
|
||||
t.hasAuthority = base.hasAuthority;
|
||||
}
|
||||
t.scheme = base.scheme;
|
||||
t.hasScheme = base.hasScheme;
|
||||
}
|
||||
t.fragment = r.fragment;
|
||||
t.hasFragment = r.hasFragment;
|
||||
return recompose(t);
|
||||
}
|
||||
|
||||
std::string requestUrl(const std::string& url) {
|
||||
Url u;
|
||||
if (!parseUrl(url, u)) return url;
|
||||
std::string out = u.scheme + "://" + u.host;
|
||||
if (u.port > 0 && u.port != 1965) out += ":" + std::to_string(u.port);
|
||||
out += u.path.empty() ? "/" : u.path;
|
||||
if (u.hasQuery) out += "?" + u.query;
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string encodeQuery(const std::string& text) {
|
||||
std::string out;
|
||||
for (unsigned char c : text) {
|
||||
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') out += static_cast<char>(c);
|
||||
else {
|
||||
char buf[4];
|
||||
std::snprintf(buf, sizeof buf, "%%%02X", c);
|
||||
out += buf;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string savedPath(const std::string& url) {
|
||||
Url u;
|
||||
if (!parseUrl(url, u)) return "/gemini/saved/unknown.gmi";
|
||||
std::string out = "/gemini/saved/" + storage::sanitize(u.host + (u.port > 0 && u.port != 1965 ? "_" + std::to_string(u.port) : ""));
|
||||
std::string path = u.path.empty() ? "/" : u.path;
|
||||
std::vector<std::string> parts;
|
||||
size_t start = 1;
|
||||
while (start <= path.size()) {
|
||||
size_t slash = path.find('/', start);
|
||||
if (slash == std::string::npos) slash = path.size();
|
||||
parts.push_back(path.substr(start, slash - start));
|
||||
start = slash + 1;
|
||||
}
|
||||
if (parts.empty() || parts.back().empty()) {
|
||||
if (!parts.empty()) parts.pop_back();
|
||||
parts.push_back("index");
|
||||
}
|
||||
for (size_t i = 0; i < parts.size(); i++) {
|
||||
std::string name = parts[i];
|
||||
if (i + 1 == parts.size()) {
|
||||
if (u.hasQuery) name += "~" + u.query;
|
||||
if (name.size() < 4 || name.compare(name.size() - 4, 4, ".gmi") != 0) name += ".gmi";
|
||||
}
|
||||
out += "/" + storage::sanitize(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace roro::gemini
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace roro::gemini {
|
||||
|
||||
// A URL split per RFC 3986 (appendix B). For Gemini, the host is lower-cased and the port
|
||||
// defaults to 1965.
|
||||
struct Url {
|
||||
std::string scheme, authority, host, path, query, fragment;
|
||||
int port = -1;
|
||||
bool hasAuthority = false, hasQuery = false, hasFragment = false;
|
||||
int portOrDefault() const { return port > 0 ? port : 1965; }
|
||||
};
|
||||
|
||||
// False unless it has a scheme and a host: only absolute URLs are fetched.
|
||||
bool parseUrl(const std::string& text, Url& out);
|
||||
bool isGemini(const std::string& url);
|
||||
// `ref` against `base`, per RFC 3986 section 5.2 (dot segments included).
|
||||
std::string resolve(const std::string& base, const std::string& ref);
|
||||
// What goes on the wire: no fragment, lower-case host, never an empty path.
|
||||
std::string requestUrl(const std::string& url);
|
||||
// For input prompts: everything but unreserved characters percent-encoded (UTF-8 bytes).
|
||||
std::string encodeQuery(const std::string& text);
|
||||
// Where a Saved Page lives (Q82): /gemini/saved/<host>[_<port>]/<path>[~<query>].gmi
|
||||
std::string savedPath(const std::string& url);
|
||||
|
||||
} // namespace roro::gemini
|
||||
@@ -0,0 +1,100 @@
|
||||
#include "gemtext.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace roro::gemini {
|
||||
|
||||
namespace {
|
||||
std::string trim(const std::string& s) {
|
||||
size_t a = s.find_first_not_of(" \t"), b = s.find_last_not_of(" \t");
|
||||
return a == std::string::npos ? "" : s.substr(a, b - a + 1);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
GemLine parseGemLine(const std::string& line, bool& pre) {
|
||||
GemLine g;
|
||||
if (line.compare(0, 3, "```") == 0) {
|
||||
g.type = LineType::PreToggle;
|
||||
g.text = trim(line.substr(3));
|
||||
pre = !pre;
|
||||
} else if (pre) {
|
||||
g.type = LineType::Preformatted;
|
||||
g.text = line;
|
||||
} else if (line.compare(0, 2, "=>") == 0) {
|
||||
g.type = LineType::Link;
|
||||
std::string rest = trim(line.substr(2));
|
||||
size_t gap = rest.find_first_of(" \t");
|
||||
g.url = rest.substr(0, gap);
|
||||
g.text = gap == std::string::npos ? "" : trim(rest.substr(gap));
|
||||
if (g.text.empty()) g.text = g.url;
|
||||
} else if (line.compare(0, 3, "###") == 0) {
|
||||
g.type = LineType::Heading3;
|
||||
g.text = trim(line.substr(3));
|
||||
} else if (line.compare(0, 2, "##") == 0) {
|
||||
g.type = LineType::Heading2;
|
||||
g.text = trim(line.substr(2));
|
||||
} else if (line.compare(0, 1, "#") == 0) {
|
||||
g.type = LineType::Heading1;
|
||||
g.text = trim(line.substr(1));
|
||||
} else if (line.compare(0, 2, "* ") == 0) {
|
||||
g.type = LineType::ListItem;
|
||||
g.text = trim(line.substr(2));
|
||||
} else if (line.compare(0, 1, ">") == 0) {
|
||||
g.type = LineType::Quote;
|
||||
g.text = trim(line.substr(1));
|
||||
} else {
|
||||
g.text = line;
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
std::vector<GemLine> parseGemtext(const std::string& document) {
|
||||
std::vector<GemLine> lines;
|
||||
bool pre = false;
|
||||
size_t start = 0;
|
||||
while (start < document.size()) {
|
||||
size_t end = document.find('\n', start);
|
||||
if (end == std::string::npos) end = document.size();
|
||||
std::string line = document.substr(start, end - start);
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
start = end + 1;
|
||||
lines.push_back(parseGemLine(line, pre));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
std::string displayText(const std::string& s) {
|
||||
std::string out;
|
||||
size_t column = 0;
|
||||
for (size_t i = 0; i < s.size();) {
|
||||
unsigned char c = static_cast<unsigned char>(s[i]);
|
||||
if (c == '\t') {
|
||||
do out += ' ';
|
||||
while (++column % 4);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c < 0x80) {
|
||||
out += static_cast<char>(c);
|
||||
i++;
|
||||
} else {
|
||||
int len = (c & 0xE0) == 0xC0 ? 2 : (c & 0xF0) == 0xE0 ? 3 : (c & 0xF8) == 0xF0 ? 4 : 0;
|
||||
bool valid = len > 0 && i + len <= s.size();
|
||||
for (int k = 1; valid && k < len; k++) valid = (static_cast<unsigned char>(s[i + k]) & 0xC0) == 0x80;
|
||||
if (!valid) {
|
||||
out += '?';
|
||||
i++;
|
||||
} else {
|
||||
uint32_t cp = len == 2 ? (c & 0x1F) : len == 3 ? (c & 0x0F) : (c & 0x07);
|
||||
for (int k = 1; k < len; k++) cp = (cp << 6) | (static_cast<unsigned char>(s[i + k]) & 0x3F);
|
||||
if (cp <= 0xFF) out.append(s, i, len); // Latin-1: the fonts have it
|
||||
else out += '?';
|
||||
i += len;
|
||||
}
|
||||
}
|
||||
column++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace roro::gemini
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace roro::gemini {
|
||||
|
||||
enum class LineType { Text, Link, Heading1, Heading2, Heading3, ListItem, Quote, PreToggle, Preformatted };
|
||||
|
||||
// One gemtext line. Link: `url` and its label in `text` (the URL itself without a label).
|
||||
// PreToggle: the ``` line, with its alt text. Preformatted: kept exactly.
|
||||
struct GemLine {
|
||||
LineType type = LineType::Text;
|
||||
std::string text, url;
|
||||
};
|
||||
|
||||
// One line; `preformatted` carries the ``` state from line to line (start with false).
|
||||
GemLine parseGemLine(const std::string& line, bool& preformatted);
|
||||
std::vector<GemLine> parseGemtext(const std::string& document);
|
||||
|
||||
// For the Latin-1 fonts (Q79): valid UTF-8 up to U+00FF kept, anything else '?', and tabs
|
||||
// expanded to 4-column stops.
|
||||
std::string displayText(const std::string& utf8);
|
||||
|
||||
} // namespace roro::gemini
|
||||
@@ -0,0 +1,47 @@
|
||||
#include "text_buffer.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace roro::gemini {
|
||||
|
||||
void TextBuffer::append(const char* data, size_t len) {
|
||||
bytes_ += len;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
if (data[i] == '\n') endLine();
|
||||
else partial_ += data[i];
|
||||
}
|
||||
}
|
||||
|
||||
void TextBuffer::finish() {
|
||||
if (!partial_.empty()) endLine();
|
||||
}
|
||||
|
||||
void TextBuffer::endLine() {
|
||||
if (!partial_.empty() && partial_.back() == '\r') partial_.pop_back();
|
||||
size_t len = std::min<size_t>(partial_.size(), 0xFFFF);
|
||||
// A line never spans two chunks: a new chunk when it doesn't fit (bigger if the line is).
|
||||
if (chunks_.empty() || chunks_.back().size() + len > chunks_.back().capacity()) {
|
||||
chunks_.emplace_back();
|
||||
chunks_.back().reserve(std::max(kChunk, len));
|
||||
}
|
||||
std::string& chunk = chunks_.back();
|
||||
lines_.push_back({static_cast<uint16_t>(chunks_.size() - 1), static_cast<uint16_t>(chunk.size()),
|
||||
static_cast<uint16_t>(len)});
|
||||
chunk.append(partial_, 0, len);
|
||||
partial_.clear();
|
||||
}
|
||||
|
||||
std::string TextBuffer::line(size_t i) const {
|
||||
if (i >= lines_.size()) return "";
|
||||
const Ref& r = lines_[i];
|
||||
return chunks_[r.chunk].substr(r.offset, r.length);
|
||||
}
|
||||
|
||||
void TextBuffer::clear() {
|
||||
chunks_.clear();
|
||||
lines_.clear();
|
||||
partial_.clear();
|
||||
bytes_ = 0;
|
||||
}
|
||||
|
||||
} // namespace roro::gemini
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace roro::gemini {
|
||||
|
||||
// A page's text, kept as lines in 4 KB chunks: no large contiguous block (with IRC connected the
|
||||
// largest free block is about 31 KB) and no copy when it grows. About 4 bytes per line on top of
|
||||
// the text itself. Bytes are fed as they arrive; lines end at LF, a CR before it is dropped.
|
||||
class TextBuffer {
|
||||
public:
|
||||
static constexpr size_t kChunk = 4096;
|
||||
|
||||
void append(const char* data, size_t len);
|
||||
void finish(); // the last line, if it has no line end
|
||||
size_t lineCount() const { return lines_.size(); }
|
||||
std::string line(size_t i) const;
|
||||
size_t bytes() const { return bytes_; }
|
||||
void clear();
|
||||
|
||||
private:
|
||||
struct Ref {
|
||||
uint16_t chunk;
|
||||
uint16_t offset;
|
||||
uint16_t length;
|
||||
};
|
||||
void endLine();
|
||||
|
||||
std::vector<std::string> chunks_;
|
||||
std::vector<Ref> lines_;
|
||||
std::string partial_; // the line being received
|
||||
size_t bytes_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro::gemini
|
||||
@@ -0,0 +1,527 @@
|
||||
#include "gemini_app.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "gemini_url.h"
|
||||
#include "text_wrap.h"
|
||||
#include "ui/fonts.h"
|
||||
#include "ui/theme.h"
|
||||
#include "ui/widgets.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
using namespace gemini;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kUrlBar = 10; // the small line with the URL at the top
|
||||
constexpr int kMargin = 3;
|
||||
constexpr const char* kStartUrl = GeminiService::kStartUrl;
|
||||
constexpr size_t kMaxHistory = 20; // Q77
|
||||
|
||||
// How each line type looks: its prefix, the indent of its wrapped rows, font and colour.
|
||||
struct Style {
|
||||
const char* prefix;
|
||||
int indent; // characters
|
||||
const lgfx::U8g2font* font;
|
||||
uint16_t color;
|
||||
};
|
||||
|
||||
Style styleOf(LineType t) {
|
||||
switch (t) {
|
||||
case LineType::Heading1: return {"", 0, &fonts::bold, theme::kAccent};
|
||||
case LineType::Heading2: return {"", 0, &fonts::bold, theme::kText};
|
||||
case LineType::Heading3: return {"", 0, &fonts::bold, theme::kMuted};
|
||||
case LineType::ListItem: return {"\xC2\xB7 ", 2, &fonts::body, theme::kText}; // ·
|
||||
case LineType::Quote: return {"> ", 2, &fonts::body, theme::kMuted};
|
||||
case LineType::Link: return {"\xC2\xBB ", 2, &fonts::body, theme::kAccent}; // »
|
||||
case LineType::Preformatted: return {"", 0, &fonts::body, theme::kText};
|
||||
default: return {"", 0, &fonts::body, theme::kText};
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void GeminiApp::onEnter() {
|
||||
if (page_.url.empty() && !loading_) navigate(kStartUrl, false);
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
// The page's first heading, for a bookmark's label.
|
||||
std::string GeminiApp::titleOf() const {
|
||||
for (size_t i = 0; i < page_.text.lineCount() && i < 40; i++)
|
||||
if (static_cast<LineType>(types_[i]) == LineType::Heading1) return lineAt(i).text;
|
||||
return "";
|
||||
}
|
||||
|
||||
void GeminiApp::follow(const std::string& link) {
|
||||
std::string url = resolve(page_.base(), link);
|
||||
navigate(url, true, isSaved()); // inside a Saved Page, its saved links open offline (Q84)
|
||||
}
|
||||
|
||||
void GeminiApp::showMessage(const std::string& url, const std::string& gemtext) {
|
||||
GeminiPage p;
|
||||
p.outcome = GeminiPage::Outcome::Ok;
|
||||
p.url = url;
|
||||
p.header.status = 20;
|
||||
p.text.append(gemtext.data(), gemtext.size());
|
||||
p.text.finish();
|
||||
show(std::move(p));
|
||||
}
|
||||
|
||||
void GeminiApp::navigate(const std::string& target, bool remember, bool preferSaved) {
|
||||
bool local = target == kStartUrl || target.rfind("file://", 0) == 0;
|
||||
if (!isGemini(target) && !local) {
|
||||
status_ = "Not Gemini: " + target;
|
||||
statusMs_ = millis();
|
||||
requestRedraw();
|
||||
return;
|
||||
}
|
||||
if (!gemini_.fetch(target, preferSaved)) {
|
||||
status_ = "Busy: try again in a moment";
|
||||
statusMs_ = millis();
|
||||
return;
|
||||
}
|
||||
if (remember && !page_.url.empty()) {
|
||||
history_.push_back({page_.url, topRow_});
|
||||
if (history_.size() > kMaxHistory) history_.erase(history_.begin());
|
||||
}
|
||||
loading_ = true;
|
||||
loadingUrl_ = target;
|
||||
status_.clear();
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
void GeminiApp::show(GeminiPage&& page) {
|
||||
page_ = std::move(page);
|
||||
windowLoading_ = false;
|
||||
analyse();
|
||||
topRow_ = hscroll_ = 0;
|
||||
selected_ = -1;
|
||||
}
|
||||
|
||||
void GeminiApp::analyse() {
|
||||
types_.clear();
|
||||
links_.clear();
|
||||
// A window may start inside a preformatted block: the index says (bit 31).
|
||||
size_t entry = page_.windowStart / GeminiService::kIndexEvery;
|
||||
bool pre = page_.windowed && entry < page_.lineIndex.size() && (page_.lineIndex[entry] & 0x80000000u);
|
||||
for (size_t i = 0; i < page_.text.lineCount(); i++) {
|
||||
LineType t = parseGemLine(page_.text.line(i), pre).type;
|
||||
if (page_.header.mimeType() != "text/gemini") t = LineType::Preformatted; // other text/*: as is
|
||||
types_.push_back(static_cast<uint8_t>(t));
|
||||
if (t == LineType::Link) links_.push_back(i);
|
||||
}
|
||||
layoutDirty_ = true;
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
size_t GeminiApp::topLine() const {
|
||||
if (firstRow_.size() < 2) return page_.windowStart;
|
||||
size_t i = std::upper_bound(firstRow_.begin(), firstRow_.end() - 1, static_cast<uint32_t>(topRow_)) - firstRow_.begin();
|
||||
return page_.windowStart + (i > 0 ? i - 1 : 0);
|
||||
}
|
||||
|
||||
void GeminiApp::requestWindow(size_t firstLine) {
|
||||
if (windowLoading_ || !gemini_.loadWindow(page_, firstLine)) return;
|
||||
windowLoading_ = true;
|
||||
keepLine_ = static_cast<long>(topLine());
|
||||
status_ = "Reading from the card...";
|
||||
statusMs_ = millis();
|
||||
}
|
||||
|
||||
// A new window of the same page: same URL, history and position, other lines.
|
||||
void GeminiApp::applyWindow(GeminiPage&& window) {
|
||||
windowLoading_ = false;
|
||||
status_.clear();
|
||||
page_.text = std::move(window.text);
|
||||
page_.windowStart = window.windowStart;
|
||||
page_.lineIndex = std::move(window.lineIndex);
|
||||
selected_ = -1;
|
||||
analyse();
|
||||
}
|
||||
|
||||
void GeminiApp::update(uint32_t nowMs) {
|
||||
GeminiPage result;
|
||||
bool got = gemini_.takeResult(result);
|
||||
if (got && !result.windowOf.empty()) {
|
||||
windowLoading_ = false;
|
||||
if (result.windowOf == page_.backing) applyWindow(std::move(result)); // else: we've moved on
|
||||
} else if (got) {
|
||||
loading_ = false;
|
||||
std::string url = result.url;
|
||||
switch (result.outcome) {
|
||||
case GeminiPage::Outcome::CertificateChanged:
|
||||
pendingCert_ = std::move(result);
|
||||
certDialog_.reset(new DialogModel({"Cancel", "Trust it"}));
|
||||
break;
|
||||
case GeminiPage::Outcome::Failed:
|
||||
showMessage(url, "# Can't open this page\n" + result.error + "\n=> " + url + " Try again\n");
|
||||
break;
|
||||
case GeminiPage::Outcome::Ok:
|
||||
switch (result.header.category()) {
|
||||
case Category::Success:
|
||||
if (result.header.mimeType().rfind("text/", 0) == 0) show(std::move(result));
|
||||
else {
|
||||
std::string mime = result.header.mimeType();
|
||||
showMessage(url, "# Not a text page\nThis is " + mime +
|
||||
", which the Cardputer can't show. s saves it to the card, in "
|
||||
"/gemini/downloads.\n");
|
||||
page_.header.meta = mime; // so s knows to download it
|
||||
}
|
||||
break;
|
||||
case Category::Input:
|
||||
// Q72: ask, then request the same URL with the answer as its query.
|
||||
inputOpen_ = true;
|
||||
inputSensitive_ = result.header.sensitiveInput();
|
||||
inputPrompt_ = result.header.meta;
|
||||
inputUrl_ = url;
|
||||
input_.setText("");
|
||||
break;
|
||||
default:
|
||||
showMessage(url, "# " + std::to_string(result.header.status) + " " + result.header.meta +
|
||||
"\nThe server refused this page.\n=> " + url + " Try again\n");
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
requestRedraw();
|
||||
}
|
||||
// Signed: statusMs_ may be a few ms ahead of this pass's nowMs (set by a key handled after it).
|
||||
std::string message;
|
||||
if (gemini_.takeMessage(message)) {
|
||||
status_ = message;
|
||||
statusMs_ = millis();
|
||||
if (!reopen_.empty()) { // a refreshed Saved Page: show the new copy
|
||||
navigate(reopen_, false);
|
||||
reopen_.clear();
|
||||
} else if (message == "Deleted") navigate(kStartUrl, false);
|
||||
requestRedraw();
|
||||
}
|
||||
if (!status_.empty() && static_cast<int32_t>(nowMs - statusMs_) > 4000) {
|
||||
status_.clear();
|
||||
requestRedraw();
|
||||
}
|
||||
}
|
||||
|
||||
int GeminiApp::rowsOnScreen() const { return (theme::kContent.h - kUrlBar) / theme::kLineHeight; }
|
||||
|
||||
LineType typeOf(const std::vector<uint8_t>& types, size_t i) { return static_cast<LineType>(types[i]); }
|
||||
|
||||
GemLine GeminiApp::lineAt(size_t i) const {
|
||||
bool pre = typeOf(types_, i) == LineType::Preformatted;
|
||||
GemLine g = parseGemLine(page_.text.line(i), pre);
|
||||
if (typeOf(types_, i) == LineType::Preformatted) {
|
||||
g.type = LineType::Preformatted;
|
||||
g.text = page_.text.line(i);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
// A line's rows, as drawn: prefix on the first, indent on the rest; preformatted lines don't wrap.
|
||||
std::vector<std::string> GeminiApp::rowsOf(Canvas& c, size_t i) const {
|
||||
GemLine g = lineAt(i);
|
||||
if (g.type == LineType::PreToggle) return {};
|
||||
if (g.type == LineType::Preformatted) return {displayText(g.text)};
|
||||
Style st = styleOf(g.type);
|
||||
c.setFont(st.font);
|
||||
MeasureText measure = [&c](const std::string& s) { return static_cast<int>(c.textWidth(s.c_str())); };
|
||||
int width = theme::kWidth - 2 * kMargin;
|
||||
std::string indent(st.indent, ' ');
|
||||
std::vector<std::string> rows = wrapText(st.prefix + displayText(g.text), width, measure);
|
||||
if (rows.empty()) rows.push_back("");
|
||||
// Rows after the first: the exact rest of the text (wrapText keeps every space but the one it
|
||||
// breaks at), wrapped again, narrower, behind the indent.
|
||||
if (rows.size() > 1 && st.indent > 0) {
|
||||
std::string full = st.prefix + displayText(g.text);
|
||||
std::string rest = full.substr(rows[0].size());
|
||||
if (!rest.empty() && rest[0] == ' ') rest.erase(0, 1);
|
||||
std::vector<std::string> more = wrapText(rest, width - measure(indent), measure);
|
||||
rows.resize(1);
|
||||
for (auto& m : more) rows.push_back(indent + m);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
void GeminiApp::layout(Canvas& c) {
|
||||
firstRow_.assign(page_.text.lineCount() + 1, 0);
|
||||
uint32_t row = 0;
|
||||
for (size_t i = 0; i < page_.text.lineCount(); i++) {
|
||||
firstRow_[i] = row;
|
||||
row += rowsOf(c, i).size();
|
||||
}
|
||||
firstRow_.back() = row;
|
||||
layoutDirty_ = false;
|
||||
}
|
||||
|
||||
void GeminiApp::scrollTo(int row) {
|
||||
int total = firstRow_.empty() ? 0 : static_cast<int>(firstRow_.back());
|
||||
// A windowed page: near either end of the window, read the next part from the card. The new
|
||||
// window starts a block before the line on top, so what's on screen stays.
|
||||
const size_t block = GeminiService::kIndexEvery;
|
||||
if (row + rowsOnScreen() > total - 2 && moreBelow()) {
|
||||
size_t line = topLine();
|
||||
requestWindow(line >= block ? line - line % block : 0);
|
||||
} else if (row < 0 && moreAbove()) {
|
||||
size_t line = topLine();
|
||||
size_t back = page_.text.lineCount() / 2; // about half a window above
|
||||
requestWindow(line > back ? line - back : 0);
|
||||
}
|
||||
topRow_ = std::max(0, std::min(row, std::max(0, total - rowsOnScreen())));
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
void GeminiApp::selectLink(int direction) {
|
||||
// A windowed page: past the window's last (or first) link, page on instead of wrapping.
|
||||
int last = static_cast<int>(links_.size()) - 1;
|
||||
if ((direction > 0 && moreBelow() && (links_.empty() || selected_ == last)) ||
|
||||
(direction < 0 && moreAbove() && (links_.empty() || selected_ == 0))) {
|
||||
selected_ = -1;
|
||||
scrollTo(topRow_ + direction * (rowsOnScreen() - 1));
|
||||
return;
|
||||
}
|
||||
if (links_.empty()) return;
|
||||
int count = static_cast<int>(links_.size());
|
||||
if (selected_ < 0) {
|
||||
// The first link on screen, or after it when going forward.
|
||||
selected_ = direction > 0 ? 0 : count - 1;
|
||||
for (int k = 0; k < count; k++)
|
||||
if (static_cast<int>(firstRow_[links_[k]]) >= topRow_) {
|
||||
selected_ = direction > 0 ? k : std::max(0, k - 1);
|
||||
break;
|
||||
}
|
||||
} else selected_ = (selected_ + direction + count) % count;
|
||||
int row = firstRow_[links_[selected_]];
|
||||
if (row < topRow_ || row >= topRow_ + rowsOnScreen()) scrollTo(row - 1);
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
bool GeminiApp::onKey(const KeyEvent& e) {
|
||||
requestRedraw();
|
||||
if (certDialog_) {
|
||||
certDialog_->onKey(e);
|
||||
if (certDialog_->result() == DialogModel::kPending) return true;
|
||||
if (certDialog_->result() == 1) {
|
||||
gemini_.trust(pendingCert_.host, pendingCert_.port, pendingCert_.presented);
|
||||
navigate(pendingCert_.requested, false);
|
||||
}
|
||||
certDialog_.reset();
|
||||
return true;
|
||||
}
|
||||
if (deleteDialog_) {
|
||||
deleteDialog_->onKey(e);
|
||||
if (deleteDialog_->result() == DialogModel::kPending) return true;
|
||||
if (deleteDialog_->result() == 1) gemini_.deleteSaved(savedPathOf());
|
||||
deleteDialog_.reset();
|
||||
return true;
|
||||
}
|
||||
if (inputOpen_) {
|
||||
switch (e.key) {
|
||||
case Key::Char: input_.insert(e.ch); break;
|
||||
case Key::Delete: input_.backspace(); break;
|
||||
case Key::Left: input_.left(); break;
|
||||
case Key::Right: input_.right(); break;
|
||||
case Key::Back: inputOpen_ = false; break;
|
||||
case Key::Select:
|
||||
inputOpen_ = false;
|
||||
navigate(resolve(inputUrl_, "?" + encodeQuery(input_.text())));
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (addressOpen_) {
|
||||
switch (e.key) {
|
||||
case Key::Char: address_.insert(e.ch); break;
|
||||
case Key::Delete: address_.backspace(); break;
|
||||
case Key::Left: address_.left(); break;
|
||||
case Key::Right: address_.right(); break;
|
||||
case Key::Back: addressOpen_ = false; break;
|
||||
case Key::Select: {
|
||||
std::string url = address_.text();
|
||||
if (url.find("://") == std::string::npos) url = "gemini://" + url;
|
||||
addressOpen_ = false;
|
||||
navigate(url);
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
switch (e.key) {
|
||||
case Key::Up: scrollTo(topRow_ - 1); return true;
|
||||
case Key::Down: scrollTo(topRow_ + 1); return true;
|
||||
case Key::Left: hscroll_ = std::max(0, hscroll_ - 8); return true;
|
||||
case Key::Right: hscroll_ += 8; return true;
|
||||
case Key::Tab: selectLink(e.shift ? -1 : 1); return true;
|
||||
case Key::Select:
|
||||
if (selected_ >= 0) follow(lineAt(links_[selected_]).url);
|
||||
return true;
|
||||
case Key::Back:
|
||||
case Key::Delete:
|
||||
if (loading_) {
|
||||
gemini_.cancel();
|
||||
return true;
|
||||
}
|
||||
if (history_.empty()) return e.key == Key::Delete; // Back leaves the App
|
||||
{
|
||||
Visit v = history_.back();
|
||||
history_.pop_back();
|
||||
navigate(v.url, false);
|
||||
restoreRow_ = v.topRow;
|
||||
}
|
||||
return true;
|
||||
case Key::Char:
|
||||
switch (e.ch) {
|
||||
case ' ': scrollTo(topRow_ + rowsOnScreen() - 1); break;
|
||||
case 'g':
|
||||
address_.setText(page_.url == kStartUrl || isSaved() ? "" : page_.url);
|
||||
addressOpen_ = true;
|
||||
break;
|
||||
case 'b': // Q77
|
||||
if (isGemini(page_.base())) gemini_.addBookmark(page_.base(), titleOf());
|
||||
break;
|
||||
case 's': // Q82, Q73
|
||||
if (isSaved()) status_ = "Already saved; r refreshes it";
|
||||
else if (!isGemini(page_.url)) status_ = "Only Gemini pages can be saved";
|
||||
else if (page_.header.mimeType().rfind("text/", 0) == 0) gemini_.save(page_.url);
|
||||
else gemini_.download(page_.url);
|
||||
statusMs_ = millis();
|
||||
break;
|
||||
case 'S': { // Q83: this page and the gemtext it links to on the same capsule
|
||||
Url here;
|
||||
if (isSaved() || !parseUrl(page_.url, here)) break;
|
||||
std::vector<std::string> links;
|
||||
for (uint32_t i : links_) {
|
||||
std::string target = resolve(page_.url, lineAt(i).url);
|
||||
Url u;
|
||||
if (isGemini(target) && parseUrl(target, u) && u.host == here.host && target != page_.url &&
|
||||
std::find(links.begin(), links.end(), target) == links.end())
|
||||
links.push_back(target);
|
||||
if (static_cast<int>(links.size()) >= GeminiService::kMaxLinkedPages - 1) break;
|
||||
}
|
||||
gemini_.saveWithLinks(page_.url, links);
|
||||
break;
|
||||
}
|
||||
case 'r': // Q84: a fresh copy of a Saved Page
|
||||
if (isSaved() && gemini_.refresh(savedPathOf())) reopen_ = page_.url;
|
||||
break;
|
||||
case 'd': // Q85
|
||||
if (isSaved()) deleteDialog_.reset(new DialogModel({"Keep", "Delete"}));
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
void GeminiApp::draw(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
if (layoutDirty_) {
|
||||
layout(c);
|
||||
if (restoreRow_ >= 0 && !loading_) {
|
||||
scrollTo(restoreRow_);
|
||||
restoreRow_ = -1;
|
||||
}
|
||||
if (keepLine_ >= 0 && !windowLoading_) { // a new window: back to the line that was on top
|
||||
long local = keepLine_ - static_cast<long>(page_.windowStart);
|
||||
if (local >= 0 && local < static_cast<long>(page_.text.lineCount())) topRow_ = firstRow_[local];
|
||||
keepLine_ = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// The URL bar: what's shown, or what's loading, or a passing message.
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextDatum(top_left);
|
||||
std::string bar = loading_ ? "Loading " + loadingUrl_
|
||||
: !status_.empty() ? status_
|
||||
: isSaved() ? "saved " + page_.savedOn + ": " + page_.savedFrom
|
||||
: page_.url;
|
||||
c.setTextColor(loading_ || !status_.empty() ? theme::kWarning : theme::kMuted);
|
||||
c.drawString(displayText(bar).substr(0, 47).c_str(), kMargin, area.y + 1);
|
||||
|
||||
int rows = rowsOnScreen(), y0 = area.y + kUrlBar;
|
||||
// The first source line on screen: the last whose first row is at or above topRow_.
|
||||
size_t i = std::upper_bound(firstRow_.begin(), firstRow_.end() - 1, static_cast<uint32_t>(topRow_)) -
|
||||
firstRow_.begin();
|
||||
i = i > 0 ? i - 1 : 0;
|
||||
int row = topRow_;
|
||||
while (i < page_.text.lineCount() && row < topRow_ + rows) {
|
||||
int skip = row - static_cast<int>(firstRow_[i]);
|
||||
int lineRows = static_cast<int>(firstRow_[i + 1] - firstRow_[i]);
|
||||
if (lineRows > 0) {
|
||||
drawLine(c, i, skip, y0 + (row - topRow_) * theme::kLineHeight, topRow_ + rows - row);
|
||||
row += lineRows - skip;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (page_.truncated && row < topRow_ + rows) {
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kWarning);
|
||||
c.drawString(("Cut short: " + page_.truncatedWhy).substr(0, 47).c_str(), kMargin,
|
||||
y0 + (row - topRow_) * theme::kLineHeight + 2);
|
||||
}
|
||||
// Scroll position, on the right edge: over the whole page, also when only a window is in memory.
|
||||
int total = static_cast<int>(firstRow_.back());
|
||||
int h = area.h - kUrlBar;
|
||||
if (page_.windowed && page_.totalLines > 0) {
|
||||
int barH = std::max(6, static_cast<int>(h * page_.text.lineCount() / page_.totalLines / 3));
|
||||
int y = static_cast<int>((h - barH) * topLine() / std::max<size_t>(1, page_.totalLines - 1));
|
||||
c.fillRect(area.w - 2, y0 + y, 2, barH, theme::kMuted);
|
||||
} else if (total > rows) {
|
||||
int barH = std::max(6, h * rows / total);
|
||||
c.fillRect(area.w - 2, y0 + (h - barH) * topRow_ / std::max(1, total - rows), 2, barH, theme::kMuted);
|
||||
}
|
||||
|
||||
if (addressOpen_) {
|
||||
theme::Rect box{2, area.y + area.h / 2 - 12, area.w - 4, theme::kLineHeight + 4};
|
||||
c.fillRect(box.x - 2, box.y - 12, box.w + 4, box.h + 14, theme::kBackground);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("Go to (Enter, or ` to cancel)", box.x, box.y - 10);
|
||||
widgets::lineEditor(c, address_, box);
|
||||
}
|
||||
if (inputOpen_) {
|
||||
theme::Rect box{2, area.y + area.h / 2 - 4, area.w - 4, theme::kLineHeight + 4};
|
||||
c.fillRect(0, box.y - 26, area.w, box.h + 28, theme::kBackground);
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kText);
|
||||
c.drawString(displayText(inputPrompt_).substr(0, 39).c_str(), box.x, box.y - 22);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString(inputSensitive_ ? "(hidden) Enter sends, ` cancels" : "Enter sends, ` cancels", box.x, box.y - 9);
|
||||
if (inputSensitive_) {
|
||||
LineEditor masked(1024);
|
||||
masked.setText(std::string(input_.text().size(), '*'));
|
||||
widgets::lineEditor(c, masked, box);
|
||||
} else widgets::lineEditor(c, input_, box);
|
||||
}
|
||||
if (deleteDialog_) widgets::dialog(c, "Delete this Saved Page?", page_.savedFrom, *deleteDialog_);
|
||||
if (certDialog_)
|
||||
widgets::dialog(c, "Certificate changed",
|
||||
pendingCert_.host + " now shows a different certificate. Was " + pendingCert_.pinned.substr(0, 16) +
|
||||
"..., now " + pendingCert_.presented.substr(0, 16) + "...",
|
||||
*certDialog_);
|
||||
}
|
||||
|
||||
void GeminiApp::drawLine(Canvas& c, size_t i, int firstRow, int y, int rowsLeft) {
|
||||
GemLine g = lineAt(i);
|
||||
Style st = styleOf(g.type);
|
||||
bool isSelected = selected_ >= 0 && links_[selected_] == i;
|
||||
std::vector<std::string> rows = rowsOf(c, i);
|
||||
c.setFont(st.font);
|
||||
c.setTextDatum(top_left);
|
||||
for (int r = firstRow; r < static_cast<int>(rows.size()) && r - firstRow < rowsLeft; r++) {
|
||||
int ry = y + (r - firstRow) * theme::kLineHeight;
|
||||
if (isSelected) c.fillRect(0, ry, theme::kWidth - 3, theme::kLineHeight, theme::kSelection);
|
||||
c.setTextColor(isSelected ? theme::kText : st.color);
|
||||
std::string text = rows[r];
|
||||
if (g.type == LineType::Preformatted) text = hscroll_ < static_cast<int>(text.size()) ? text.substr(hscroll_) : "";
|
||||
c.drawString(text.c_str(), kMargin, ry + 1);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app.h"
|
||||
#include "dialog_model.h"
|
||||
#include "gemtext.h"
|
||||
#include "line_editor.h"
|
||||
#include "services/gemini_service.h"
|
||||
#include "ui/canvas.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// Geminispace on the Cardputer (docs/milestones/G1.md). Pages come from the GeminiService as
|
||||
// lines; the App wraps them once (keeping only each line's first row), then wraps just the lines
|
||||
// on screen when it draws, so a long page costs 4 bytes a line on top of its text.
|
||||
class GeminiApp : public App {
|
||||
public:
|
||||
explicit GeminiApp(GeminiService& gemini) : gemini_(gemini) {}
|
||||
void onEnter() override;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
bool textEntryActive() const override { return addressOpen_ || inputOpen_; }
|
||||
void update(uint32_t nowMs) override;
|
||||
void draw(Canvas& c) override;
|
||||
|
||||
private:
|
||||
struct Visit {
|
||||
std::string url;
|
||||
int topRow;
|
||||
};
|
||||
|
||||
void navigate(const std::string& url, bool remember = true, bool preferSaved = false);
|
||||
void follow(const std::string& link);
|
||||
std::string titleOf() const;
|
||||
bool isSaved() const { return !page_.savedFrom.empty(); }
|
||||
std::string savedPathOf() const { return page_.url.substr(7); } // file://…
|
||||
void show(GeminiPage&& page);
|
||||
void analyse(); // types and links of the lines in memory
|
||||
void applyWindow(GeminiPage&& window);
|
||||
void requestWindow(size_t firstLine);
|
||||
size_t topLine() const; // the source line (in the whole page) at the top of the screen
|
||||
bool moreBelow() const { return page_.windowed && page_.windowStart + page_.text.lineCount() < page_.totalLines; }
|
||||
bool moreAbove() const { return page_.windowed && page_.windowStart > 0; }
|
||||
void showMessage(const std::string& url, const std::string& gemtext);
|
||||
void layout(Canvas& c);
|
||||
void drawLine(Canvas& c, size_t index, int firstRow, int y, int rows);
|
||||
std::vector<std::string> rowsOf(Canvas& c, size_t index) const;
|
||||
gemini::GemLine lineAt(size_t index) const;
|
||||
int rowsOnScreen() const;
|
||||
void selectLink(int direction);
|
||||
void scrollTo(int row);
|
||||
|
||||
GeminiService& gemini_;
|
||||
GeminiPage page_;
|
||||
std::vector<uint8_t> types_; // per source line: gemini::LineType
|
||||
std::vector<uint32_t> firstRow_; // per source line, plus the total at the end
|
||||
std::vector<uint32_t> links_; // source lines that are links
|
||||
bool layoutDirty_ = true;
|
||||
int topRow_ = 0, hscroll_ = 0;
|
||||
int selected_ = -1; // into links_
|
||||
int restoreRow_ = -1; // going back: where that page was scrolled to, applied after layout
|
||||
bool windowLoading_ = false;
|
||||
long keepLine_ = -1; // a new window: the source line to keep at the top, applied after layout
|
||||
std::vector<Visit> history_;
|
||||
bool loading_ = false;
|
||||
std::string loadingUrl_, status_;
|
||||
uint32_t statusMs_ = 0;
|
||||
bool addressOpen_ = false;
|
||||
LineEditor address_{1024};
|
||||
std::unique_ptr<DialogModel> certDialog_;
|
||||
GeminiPage pendingCert_;
|
||||
std::unique_ptr<DialogModel> deleteDialog_;
|
||||
bool inputOpen_ = false, inputSensitive_ = false;
|
||||
std::string inputPrompt_, inputUrl_;
|
||||
LineEditor input_{1024};
|
||||
std::string reopen_; // after a refresh: the Saved Page to show again
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
+17
-2
@@ -8,6 +8,7 @@
|
||||
|
||||
#include "app_manager.h"
|
||||
#include "apps/demo_app.h"
|
||||
#include "apps/gemini_app.h"
|
||||
#include "apps/gnss_app.h"
|
||||
#include "apps/irc_app.h"
|
||||
#include "apps/launcher_app.h"
|
||||
@@ -27,6 +28,7 @@
|
||||
#include "services/irc_service.h"
|
||||
#include "services/clock_service.h"
|
||||
#include "services/debug_console.h"
|
||||
#include "services/gemini_service.h"
|
||||
#include "services/gnss_service.h"
|
||||
#include "services/power_service.h"
|
||||
#include "services/storage_service.h"
|
||||
@@ -54,6 +56,7 @@ static StorageService* storageService;
|
||||
static PowerService* power;
|
||||
static ClockService* clockService;
|
||||
static GnssService* gnssService;
|
||||
static GeminiService* geminiService;
|
||||
static SavedNetworks* savedNetworks;
|
||||
static WifiService* wifi;
|
||||
static IrcService* irc;
|
||||
@@ -145,6 +148,7 @@ void setup() {
|
||||
storageService = new StorageService(bus);
|
||||
power = new PowerService(settings);
|
||||
clockService = new ClockService(settings, bus);
|
||||
geminiService = new GeminiService(nvs, *storageService, *clockService, bus);
|
||||
gnssService = new GnssService(settings, *clockService, *storageService, bus);
|
||||
savedNetworks = new SavedNetworks(nvs);
|
||||
savedNetworks->load();
|
||||
@@ -171,6 +175,7 @@ void setup() {
|
||||
apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)});
|
||||
apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService)});
|
||||
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)});
|
||||
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)});
|
||||
apps->registerApp({"settings", "Settings", false,
|
||||
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update})});
|
||||
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});
|
||||
@@ -345,8 +350,9 @@ static const char* const kHelp =
|
||||
"gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n"
|
||||
"crash the last crash: firmware, reason, task, backtrace\n"
|
||||
"coredump erase forget the core dump in flash\n"
|
||||
"key <name|char> press a key: up down left right select back home del tab, or one character\n"
|
||||
"key <name|char> press a key: up down left right select back home del tab space, or one character\n"
|
||||
"wifi status | wifi add <ssid><TAB><password>\n"
|
||||
"gemini get <url> fetch a Gemini page and report header, size, certificate, heap\n"
|
||||
"irc start | irc stop | irc dump | irc say <buffer> <text>\n"
|
||||
"ls [folder] | rm <path> | install <path.ota> (Update from SD)\n"
|
||||
"sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
|
||||
@@ -429,7 +435,9 @@ static void runCommand(String line) {
|
||||
Key key = k == "up" ? Key::Up : k == "down" ? Key::Down : k == "left" ? Key::Left
|
||||
: k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home
|
||||
: k == "del" ? Key::Delete : k == "tab" ? Key::Tab : Key::Select;
|
||||
KeyEvent ev = k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0]) : KeyEvent::of(key);
|
||||
KeyEvent ev = k == "space" ? KeyEvent::character(' ')
|
||||
: k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0])
|
||||
: KeyEvent::of(key);
|
||||
if (!power->onKey(millis())) apps->handleKey(ev);
|
||||
}
|
||||
if (line.startsWith("wifi add ")) { // wifi add <ssid>\t<password>: credentials never touch the repo
|
||||
@@ -462,6 +470,13 @@ static void runCommand(String line) {
|
||||
storageService->requestListing();
|
||||
listingWanted = true;
|
||||
}
|
||||
if (line.startsWith("gemini trust ")) { // gemini trust <host> <port> <sha256>: accept a changed certificate
|
||||
char host[96] = "", fp[80] = "";
|
||||
int port = 1965;
|
||||
if (geminiService && sscanf(line.substring(13).c_str(), "%95s %d %79s", host, &port, fp) == 3) geminiService->trust(host, port, fp);
|
||||
}
|
||||
if (line.startsWith("gemini get "))
|
||||
if (!geminiService || !geminiService->fetchToConsole(line.substring(11).c_str())) console.println("gemini: busy, or unavailable in Safe Mode");
|
||||
if (line == "irc start") irc->connect();
|
||||
if (line == "irc stop") irc->disconnect();
|
||||
if (line.startsWith("irc say ")) { // irc say <buffer index> <text>
|
||||
|
||||
@@ -187,29 +187,7 @@ bool DebugConsole::binaryCommand(NetworkClient& client, const std::string& line)
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DebugConsole::onStorage(std::function<void()> job) {
|
||||
// Shared with the job, which outlives this wait if the card is missing and it never starts.
|
||||
// Exactly one side wins the state change: the job (Queued -> Running) or the wait giving up
|
||||
// (Queued -> Abandoned), so an abandoned job can never touch this stack frame.
|
||||
enum : int { Queued, Running, Abandoned };
|
||||
struct Run {
|
||||
std::atomic<int> state{Queued};
|
||||
SemaphoreHandle_t done = xSemaphoreCreateBinary();
|
||||
~Run() { vSemaphoreDelete(done); }
|
||||
};
|
||||
auto run = std::make_shared<Run>();
|
||||
storage_.runJob([run, job]() {
|
||||
int expected = Queued;
|
||||
if (!run->state.compare_exchange_strong(expected, Running)) return;
|
||||
job();
|
||||
xSemaphoreGive(run->done);
|
||||
});
|
||||
for (int waited = 0; xSemaphoreTake(run->done, pdMS_TO_TICKS(500)) != pdTRUE; waited += 500) {
|
||||
int expected = Queued;
|
||||
if (waited >= 5000 && run->state.compare_exchange_strong(expected, Abandoned)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool DebugConsole::onStorage(std::function<void()> job) { return storage_.runAndWait(std::move(job)); }
|
||||
|
||||
void DebugConsole::get(NetworkClient& client, const std::string& path) {
|
||||
bool ran = onStorage([&]() {
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
#include "services/gemini_service.h"
|
||||
|
||||
#include <NetworkClientSecure.h>
|
||||
#include <SD.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_heap_caps.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
|
||||
#include "gemini_url.h"
|
||||
#include "gemtext.h"
|
||||
#include "platform/console.h"
|
||||
#include "sha256.h"
|
||||
#include "storage_paths.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
using gemini::TextBuffer;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint32_t kIdleTimeoutMs = 15000;
|
||||
constexpr const char* kSavedRoot = "/gemini/saved";
|
||||
constexpr const char* kSavedMark = "> Saved from "; // first line of a Saved Page (Q82)
|
||||
constexpr const char* kDefaults =
|
||||
"## Start here\n"
|
||||
"=> gemini://geminiprotocol.net/ Project Gemini\n"
|
||||
"=> gemini://kennedy.gemi.dev/ Kennedy: search Geminispace\n"
|
||||
"=> gemini://skyjake.fi/~Cosmos/ Cosmos: what's new on Geminispace\n"
|
||||
"=> gemini://bbs.geminispace.org/ Bubble: a bulletin board\n";
|
||||
|
||||
std::string hex(const uint8_t* data, size_t len) {
|
||||
static const char* digits = "0123456789abcdef";
|
||||
std::string out;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
out += digits[data[i] >> 4];
|
||||
out += digits[data[i] & 15];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Folders for a file path, one at a time (SD.mkdir makes one level).
|
||||
void makeParents(const std::string& path) {
|
||||
for (size_t slash = path.find('/', 1); slash != std::string::npos; slash = path.find('/', slash + 1)) {
|
||||
std::string dir = path.substr(0, slash);
|
||||
if (!SD.exists(dir.c_str())) SD.mkdir(dir.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// Copies `from` to `to`, optionally after a first line. On the storage task.
|
||||
bool copyFile(const char* from, const std::string& to, const std::string& firstLine) {
|
||||
File in = SD.open(from);
|
||||
if (!in) return false;
|
||||
makeParents(to);
|
||||
File out = SD.open(to.c_str(), FILE_WRITE);
|
||||
if (!out) return false;
|
||||
bool ok = firstLine.empty() || out.print((firstLine + "\n").c_str()) > 0;
|
||||
uint8_t buf[512];
|
||||
int n;
|
||||
while (ok && (n = in.read(buf, sizeof buf)) > 0) ok = out.write(buf, n) == static_cast<size_t>(n);
|
||||
out.close();
|
||||
in.close();
|
||||
if (!ok) SD.remove(to.c_str());
|
||||
return ok;
|
||||
}
|
||||
|
||||
struct SavedEntry {
|
||||
std::string path; // /gemini/saved/host/…/name.gmi
|
||||
time_t when;
|
||||
};
|
||||
|
||||
void listSaved(const std::string& dir, std::vector<SavedEntry>& out) {
|
||||
File d = SD.open(dir.c_str());
|
||||
if (!d || !d.isDirectory()) return;
|
||||
for (File f = d.openNextFile(); f && out.size() < 200; f = d.openNextFile()) {
|
||||
std::string path = dir + "/" + f.name();
|
||||
if (f.isDirectory()) listSaved(path, out);
|
||||
else out.push_back({path, f.getLastWrite()});
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char* GeminiService::slotPath(int slot) {
|
||||
static const char* const kPaths[] = {"/gemini/cache/page0.gmi", "/gemini/cache/page1.gmi", "/gemini/cache/work.gmi"};
|
||||
return kPaths[slot];
|
||||
}
|
||||
|
||||
const char* GeminiService::cacheOf(const std::string& url) const {
|
||||
for (int s = 0; s < 3; s++)
|
||||
if (!url.empty() && slotUrl_[s] == url) return slotPath(s);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
GeminiService::GeminiService(KeyValueStore& store, StorageService& storage, ClockService& clock, EventBus& bus)
|
||||
: store_(store), storage_(storage), clock_(clock), bus_(bus), lock_(xSemaphoreCreateMutex()) {}
|
||||
|
||||
// NVS keys are 15 characters at most: "gm" and 12 hex digits of SHA-256("host:port").
|
||||
std::string GeminiService::pinKey(const std::string& host, int port) const {
|
||||
std::string id = host + ":" + std::to_string(port);
|
||||
uint8_t digest[32];
|
||||
Sha256::hash(reinterpret_cast<const uint8_t*>(id.data()), id.size(), digest);
|
||||
return "gm" + hex(digest, 6);
|
||||
}
|
||||
|
||||
void GeminiService::trust(const std::string& host, int port, const std::string& fingerprint) {
|
||||
store_.putString(pinKey(host, port).c_str(), fingerprint);
|
||||
}
|
||||
|
||||
std::string GeminiService::now() const {
|
||||
int64_t t = clock_.utcNow();
|
||||
return t < 0 ? "an unknown date" : ClockModel::formatLocalDate(t) + " " + ClockModel::formatLocalTime(t);
|
||||
}
|
||||
|
||||
void GeminiService::notify(const std::string& text) {
|
||||
bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(NotificationLevel::Info)));
|
||||
}
|
||||
|
||||
bool GeminiService::start(Job job) {
|
||||
if (busy_) return false;
|
||||
busy_ = true;
|
||||
ready_ = messageReady_ = cancel_ = false;
|
||||
job_ = job;
|
||||
if (xTaskCreate(taskEntry, "gemini", 6144, this, 1, nullptr) != pdPASS) { // peak 3.6 KB (step 1)
|
||||
busy_ = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool GeminiService::fetch(const std::string& url, bool preferSaved) {
|
||||
if (busy_) return false;
|
||||
arg_ = url;
|
||||
preferSaved_ = preferSaved;
|
||||
return start(Job::Fetch);
|
||||
}
|
||||
|
||||
bool GeminiService::fetchToConsole(const std::string& url) {
|
||||
if (busy_) return false;
|
||||
toConsole_ = true;
|
||||
if (!fetch(url)) {
|
||||
toConsole_ = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool GeminiService::save(const std::string& url) {
|
||||
if (busy_) return false;
|
||||
arg_ = url;
|
||||
return start(Job::Save);
|
||||
}
|
||||
|
||||
bool GeminiService::saveWithLinks(const std::string& url, std::vector<std::string> links) {
|
||||
if (busy_) return false;
|
||||
arg_ = url;
|
||||
links_ = std::move(links);
|
||||
return start(Job::SaveWithLinks);
|
||||
}
|
||||
|
||||
bool GeminiService::refresh(const std::string& savedPath) {
|
||||
if (busy_) return false;
|
||||
arg_ = savedPath;
|
||||
return start(Job::Refresh);
|
||||
}
|
||||
|
||||
bool GeminiService::deleteSaved(const std::string& savedPath) {
|
||||
if (busy_) return false;
|
||||
arg_ = savedPath;
|
||||
return start(Job::Delete);
|
||||
}
|
||||
|
||||
bool GeminiService::addBookmark(const std::string& url, const std::string& title) {
|
||||
if (busy_) return false;
|
||||
arg_ = url;
|
||||
arg2_ = title;
|
||||
return start(Job::Bookmark);
|
||||
}
|
||||
|
||||
bool GeminiService::download(const std::string& url) {
|
||||
if (busy_) return false;
|
||||
arg_ = url;
|
||||
return start(Job::Download);
|
||||
}
|
||||
|
||||
bool GeminiService::loadWindow(const GeminiPage& page, size_t firstLine) {
|
||||
if (busy_ || page.backing.empty()) return false;
|
||||
windowPage_ = GeminiPage();
|
||||
windowPage_.backing = page.backing;
|
||||
windowPage_.totalLines = page.totalLines;
|
||||
windowPage_.lineIndex = page.lineIndex;
|
||||
windowLine_ = firstLine;
|
||||
// The App drops its current window once the new one is in: count that memory as coming back.
|
||||
windowReleasing_ = page.text.bytes() + page.text.lineCount() * 8;
|
||||
return start(Job::Window);
|
||||
}
|
||||
|
||||
bool GeminiService::takeResult(GeminiPage& out) {
|
||||
if (!ready_) return false;
|
||||
xSemaphoreTake(lock_, portMAX_DELAY);
|
||||
out = std::move(result_);
|
||||
result_ = GeminiPage();
|
||||
ready_ = false;
|
||||
xSemaphoreGive(lock_);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool GeminiService::takeMessage(std::string& out) {
|
||||
if (!messageReady_) return false;
|
||||
xSemaphoreTake(lock_, portMAX_DELAY);
|
||||
out = std::move(message_);
|
||||
messageReady_ = false;
|
||||
xSemaphoreGive(lock_);
|
||||
return true;
|
||||
}
|
||||
|
||||
void GeminiService::finishWithMessage(const std::string& text) {
|
||||
xSemaphoreTake(lock_, portMAX_DELAY);
|
||||
message_ = text;
|
||||
messageReady_ = true;
|
||||
xSemaphoreGive(lock_);
|
||||
}
|
||||
|
||||
void GeminiService::taskEntry(void* self) {
|
||||
static_cast<GeminiService*>(self)->run();
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
void GeminiService::run() {
|
||||
switch (job_) {
|
||||
case Job::Fetch: {
|
||||
GeminiPage page;
|
||||
runFetch(page);
|
||||
if (!toConsole_) {
|
||||
xSemaphoreTake(lock_, portMAX_DELAY);
|
||||
result_ = std::move(page);
|
||||
ready_ = true;
|
||||
xSemaphoreGive(lock_);
|
||||
}
|
||||
toConsole_ = false;
|
||||
break;
|
||||
}
|
||||
case Job::Save: {
|
||||
std::string why = saveCached(arg_);
|
||||
finishWithMessage(!why.empty() ? why : replaced_ ? "Saved again (replaced the older copy)" : "Saved for offline reading");
|
||||
break;
|
||||
}
|
||||
case Job::SaveWithLinks: {
|
||||
std::string why = saveCached(arg_);
|
||||
if (!why.empty()) {
|
||||
finishWithMessage(why);
|
||||
break;
|
||||
}
|
||||
int saved = 1, failed = 0, total = static_cast<int>(links_.size()) + 1;
|
||||
notify("Saving " + std::to_string(total) + " pages for offline reading");
|
||||
for (const std::string& link : links_) {
|
||||
if (cancel_) break;
|
||||
GeminiPage page;
|
||||
fetchFollowing(link, page, false, kWorkSlot);
|
||||
bool text = page.outcome == GeminiPage::Outcome::Ok &&
|
||||
page.header.category() == gemini::Category::Success && page.header.mimeType() == "text/gemini";
|
||||
if (text && saveCached(link).empty()) saved++;
|
||||
else failed++;
|
||||
if ((saved + failed) % 5 == 0) notify("Saved " + std::to_string(saved) + " of " + std::to_string(total));
|
||||
}
|
||||
std::string done = "Saved " + std::to_string(saved) + " of " + std::to_string(total) + " pages" +
|
||||
(failed ? " (" + std::to_string(failed) + " not gemtext or failed)" : "");
|
||||
notify(done);
|
||||
finishWithMessage(done);
|
||||
break;
|
||||
}
|
||||
case Job::Refresh: {
|
||||
// Only the first line: where it came from. (Loading the whole page here, next to the
|
||||
// App's copy and a TLS connection, once took the heap down to 436 bytes.)
|
||||
std::string from = savedFrom(arg_);
|
||||
if (from.empty()) {
|
||||
finishWithMessage("Not a Saved Page");
|
||||
break;
|
||||
}
|
||||
GeminiPage page;
|
||||
fetchFollowing(from, page, false, kWorkSlot);
|
||||
std::string why = page.outcome != GeminiPage::Outcome::Ok ? page.error
|
||||
: page.header.category() != gemini::Category::Success ? page.header.meta
|
||||
: saveCached(from);
|
||||
finishWithMessage(why.empty() ? "Refreshed" : "Not refreshed: " + why);
|
||||
break;
|
||||
}
|
||||
case Job::Window: {
|
||||
// Q86 both ways: old and new together above the transient floor for a moment, and the
|
||||
// new one alone (once the old is dropped) leaving the steady floor.
|
||||
size_t free = esp_get_free_heap_size();
|
||||
size_t transient = free > kTransientFloor + TextBuffer::kChunk ? free - kTransientFloor - TextBuffer::kChunk : 0;
|
||||
size_t steady = free + windowReleasing_ > kSteadyFloor + TextBuffer::kChunk
|
||||
? free + windowReleasing_ - kSteadyFloor - TextBuffer::kChunk
|
||||
: 0;
|
||||
size_t budget = std::min(transient, steady);
|
||||
GeminiPage page = std::move(windowPage_);
|
||||
page.windowStart = windowLine_ - windowLine_ % kIndexEvery;
|
||||
page.windowed = true;
|
||||
page.windowOf = page.backing;
|
||||
page.outcome = GeminiPage::Outcome::Ok;
|
||||
readWindow(page, page.windowStart, std::min(budget, kMaxBody));
|
||||
console.printf("gemini: window from line %u: %u lines\n", (unsigned)page.windowStart,
|
||||
(unsigned)page.text.lineCount());
|
||||
xSemaphoreTake(lock_, portMAX_DELAY);
|
||||
result_ = std::move(page);
|
||||
ready_ = true;
|
||||
xSemaphoreGive(lock_);
|
||||
break;
|
||||
}
|
||||
case Job::Delete: {
|
||||
bool removed = false;
|
||||
bool ran = storage_.runAndWait([&]() { removed = SD.remove(arg_.c_str()); });
|
||||
finishWithMessage(!ran ? "No SD card" : removed ? "Deleted" : "Not deleted");
|
||||
break;
|
||||
}
|
||||
case Job::Bookmark: {
|
||||
std::string line = "=> " + arg_ + (arg2_.empty() ? "" : " " + arg2_), already;
|
||||
bool ran = storage_.runAndWait([&]() {
|
||||
File f = SD.open(kBookmarks);
|
||||
while (f && f.available()) {
|
||||
String l = f.readStringUntil('\n');
|
||||
if (l.startsWith(("=> " + arg_ + " ").c_str()) || l == ("=> " + arg_).c_str()) already = "yes";
|
||||
}
|
||||
if (f) f.close();
|
||||
if (!already.empty()) return;
|
||||
makeParents(kBookmarks);
|
||||
File out = SD.open(kBookmarks, FILE_APPEND);
|
||||
if (out) {
|
||||
out.print((line + "\n").c_str());
|
||||
out.close();
|
||||
}
|
||||
});
|
||||
finishWithMessage(!ran ? "No SD card" : !already.empty() ? "Already bookmarked" : "Bookmarked");
|
||||
break;
|
||||
}
|
||||
case Job::Download: {
|
||||
gemini::Url u;
|
||||
std::string name = gemini::parseUrl(arg_, u) ? u.path.substr(u.path.rfind('/') + 1) : "";
|
||||
if (name.empty()) name = "download";
|
||||
std::string to = "/gemini/downloads/" + storage::sanitize(name);
|
||||
bool copied = false;
|
||||
if (const char* from = cacheOf(arg_)) storage_.runAndWait([&]() { copied = copyFile(from, to, ""); });
|
||||
finishWithMessage(copied ? "Saved to " + to : "Not saved (no card, or the page is gone)");
|
||||
break;
|
||||
}
|
||||
}
|
||||
busy_ = false;
|
||||
}
|
||||
|
||||
void GeminiService::runFetch(GeminiPage& page) {
|
||||
page.requested = arg_;
|
||||
size_t before = esp_get_free_heap_size();
|
||||
lowest_ = before;
|
||||
uint32_t started = millis();
|
||||
const std::string& url = arg_;
|
||||
if (url == kStartUrl) composeStart(page);
|
||||
else if (url.rfind("file://", 0) == 0) openSaved(url.substr(7), page);
|
||||
else {
|
||||
// Inside a Saved Page, a link to another Saved Page opens the saved copy (Q84).
|
||||
bool savedCopy = false;
|
||||
if (preferSaved_) {
|
||||
std::string path = gemini::savedPath(url);
|
||||
storage_.runAndWait([&]() { savedCopy = SD.exists(path.c_str()); });
|
||||
if (savedCopy) openSaved(path, page);
|
||||
}
|
||||
if (!savedCopy) {
|
||||
if (preferSaved_ && !WiFi.isConnected()) {
|
||||
page.url = url;
|
||||
page.error = "Not saved, and offline";
|
||||
} else
|
||||
{
|
||||
int slot = nextDisplay_;
|
||||
fetchFollowing(url, page, true, slot);
|
||||
if (page.outcome == GeminiPage::Outcome::Ok && !page.cachePath.empty())
|
||||
nextDisplay_ = 1 - slot; // keep this one: it backs the page on screen
|
||||
}
|
||||
}
|
||||
}
|
||||
page.ms = millis() - started;
|
||||
if (toConsole_) report(page, before, lowest_);
|
||||
}
|
||||
|
||||
// One request, following up to kMaxRedirects redirects.
|
||||
void GeminiService::fetchFollowing(const std::string& first, GeminiPage& page, bool loadIntoRam, int slot) {
|
||||
std::string url = first;
|
||||
size_t free = esp_get_free_heap_size();
|
||||
if (free < kStartFloor) { // Q86, for every fetch: pages, saves, refreshes
|
||||
page.url = url;
|
||||
page.outcome = GeminiPage::Outcome::Failed;
|
||||
page.error = "Not enough memory (" + std::to_string(free / 1024) + " KB free): stop IRC or retry";
|
||||
return;
|
||||
}
|
||||
for (int hop = 0;; hop++) {
|
||||
fetchOne(url, page, loadIntoRam, slot);
|
||||
if (page.outcome != GeminiPage::Outcome::Ok || page.header.category() != gemini::Category::Redirect) break;
|
||||
std::string next = gemini::resolve(url, page.header.meta);
|
||||
if (!gemini::isGemini(next)) {
|
||||
page.outcome = GeminiPage::Outcome::Failed;
|
||||
page.error = "Redirect to another protocol: " + next;
|
||||
break;
|
||||
}
|
||||
if (hop + 1 > kMaxRedirects) {
|
||||
page.outcome = GeminiPage::Outcome::Failed;
|
||||
page.error = "Too many redirects";
|
||||
break;
|
||||
}
|
||||
url = next;
|
||||
}
|
||||
page.url = url;
|
||||
}
|
||||
|
||||
// A Saved Page's first line: "> Saved from <url> on <date>" (Q82). Empty if it isn't one.
|
||||
std::string GeminiService::savedFirstLine(const std::string& path) {
|
||||
std::string first;
|
||||
storage_.runAndWait([&]() {
|
||||
File f = SD.open(path.c_str());
|
||||
if (!f) return;
|
||||
char buf[600];
|
||||
size_t n = f.readBytesUntil('\n', buf, sizeof buf - 1);
|
||||
buf[n] = 0;
|
||||
first = buf;
|
||||
f.close();
|
||||
});
|
||||
if (!first.empty() && first.back() == '\r') first.pop_back();
|
||||
return first.rfind(kSavedMark, 0) == 0 ? first : "";
|
||||
}
|
||||
|
||||
std::string GeminiService::savedFrom(const std::string& path) {
|
||||
std::string first = savedFirstLine(path);
|
||||
if (first.empty()) return "";
|
||||
std::string rest = first.substr(strlen(kSavedMark));
|
||||
return rest.substr(0, rest.find(" on "));
|
||||
}
|
||||
|
||||
// A Saved Page from the card, as far as memory allows; its first line says where it came from.
|
||||
void GeminiService::openSaved(const std::string& path, GeminiPage& page) {
|
||||
page.url = "file://" + path;
|
||||
page.header.status = 20;
|
||||
page.header.meta = "text/gemini";
|
||||
bool found = false;
|
||||
storage_.runAndWait([&]() { found = SD.exists(path.c_str()); });
|
||||
std::string first = found ? savedFirstLine(path) : "";
|
||||
if (!found) {
|
||||
page.error = "This Saved Page is gone";
|
||||
return;
|
||||
}
|
||||
if (first.rfind(kSavedMark, 0) == 0) {
|
||||
std::string rest = first.substr(strlen(kSavedMark));
|
||||
size_t on = rest.find(" on ");
|
||||
page.savedFrom = rest.substr(0, on);
|
||||
page.savedOn = on == std::string::npos ? "" : rest.substr(on + 4);
|
||||
}
|
||||
loadFromCard(page, esp_get_free_heap_size(), path);
|
||||
page.outcome = GeminiPage::Outcome::Ok;
|
||||
}
|
||||
|
||||
// about:start (Q78, Q84): bookmarks, then Saved Pages by capsule, newest first, then defaults.
|
||||
void GeminiService::composeStart(GeminiPage& page) {
|
||||
std::string doc = "# Gemini\nTab picks a link, Enter follows it, Back returns, g types an address. "
|
||||
"On a page: b bookmarks it, s saves it to read offline, S with the pages it links to.\n";
|
||||
std::string bookmarks;
|
||||
std::vector<SavedEntry> saved;
|
||||
bool card = storage_.runAndWait([&]() {
|
||||
File f = SD.open(kBookmarks);
|
||||
while (f && f.available() && bookmarks.size() < 8192) {
|
||||
String l = f.readStringUntil('\n');
|
||||
if (l.startsWith("=>")) bookmarks += std::string(l.c_str()) + "\n";
|
||||
}
|
||||
if (f) f.close();
|
||||
listSaved(kSavedRoot, saved);
|
||||
});
|
||||
doc += "## Bookmarks\n";
|
||||
doc += !card ? "No SD card: bookmarks and Saved Pages live on it.\n"
|
||||
: bookmarks.empty() ? "None yet: b on a page adds it here.\n" : bookmarks;
|
||||
if (!saved.empty()) {
|
||||
// Capsules ordered by their newest Saved Page, pages newest first within each.
|
||||
std::sort(saved.begin(), saved.end(), [](const SavedEntry& a, const SavedEntry& b) { return a.when > b.when; });
|
||||
std::vector<std::string> order;
|
||||
std::map<std::string, std::string> byHost;
|
||||
size_t root = strlen(kSavedRoot) + 1;
|
||||
for (auto& e : saved) {
|
||||
std::string rel = e.path.substr(root);
|
||||
std::string host = rel.substr(0, rel.find('/'));
|
||||
if (!byHost.count(host)) order.push_back(host);
|
||||
std::string label = rel.substr(host.size() + 1);
|
||||
if (label.size() > 4 && label.compare(label.size() - 4, 4, ".gmi") == 0) label.resize(label.size() - 4);
|
||||
char day[24] = "";
|
||||
if (e.when > 1000000000) {
|
||||
struct tm t;
|
||||
localtime_r(&e.when, &t);
|
||||
strftime(day, sizeof day, " (%d %b)", &t);
|
||||
}
|
||||
byHost[host] += "=> file://" + e.path + " " + label + day + "\n";
|
||||
}
|
||||
doc += "## Saved pages\n";
|
||||
for (auto& host : order) doc += "### " + host + "\n" + byHost[host];
|
||||
}
|
||||
doc += kDefaults;
|
||||
page.url = kStartUrl;
|
||||
page.header.status = 20;
|
||||
page.header.meta = "text/gemini";
|
||||
page.text.append(doc.data(), doc.size());
|
||||
page.text.finish();
|
||||
page.outcome = GeminiPage::Outcome::Ok;
|
||||
}
|
||||
|
||||
// The cached body of `url` (fetched again if the cache holds something else) to its Saved Page.
|
||||
std::string GeminiService::saveCached(const std::string& url) {
|
||||
if (!gemini::isGemini(url)) return "Only Gemini pages can be saved";
|
||||
if (!storage_.state().present) return "No SD card";
|
||||
const char* from = cacheOf(url);
|
||||
if (!from) {
|
||||
GeminiPage page;
|
||||
fetchFollowing(url, page, false, kWorkSlot);
|
||||
if (page.outcome != GeminiPage::Outcome::Ok) return page.error;
|
||||
if (page.header.category() != gemini::Category::Success) return page.header.meta;
|
||||
from = cacheOf(page.url);
|
||||
if (!from) return "Not saved";
|
||||
}
|
||||
std::string to = gemini::savedPath(url);
|
||||
bool existed = false, ok = false;
|
||||
replaced_ = false;
|
||||
std::string header = std::string(kSavedMark) + url + " on " + now();
|
||||
storage_.runAndWait([&]() {
|
||||
existed = SD.exists(to.c_str());
|
||||
ok = copyFile(from, to, header);
|
||||
});
|
||||
if (!ok) return "Writing to the SD card failed";
|
||||
replaced_ = existed; // Q82: saving again replaces it, and says so
|
||||
return "";
|
||||
}
|
||||
|
||||
void GeminiService::fetchOne(const std::string& url, GeminiPage& page, bool loadIntoRam, int slot) {
|
||||
page.outcome = GeminiPage::Outcome::Failed;
|
||||
page.text.clear();
|
||||
page.truncated = false;
|
||||
gemini::Url u;
|
||||
if (!gemini::parseUrl(url, u) || u.scheme != "gemini") {
|
||||
page.error = "Not a Gemini URL: " + url;
|
||||
return;
|
||||
}
|
||||
auto track = [this]() { lowest_ = std::min<size_t>(lowest_, esp_get_free_heap_size()); };
|
||||
|
||||
size_t freeBefore = esp_get_free_heap_size(); // what's left once the connection closes again
|
||||
NetworkClientSecure tls;
|
||||
tls.setInsecure(); // trust on first use: the pinned fingerprint is what counts (Q71)
|
||||
tls.setTimeout(15);
|
||||
if (!tls.connect(u.host.c_str(), u.portOrDefault())) {
|
||||
page.error = "Cannot connect to " + u.host;
|
||||
return;
|
||||
}
|
||||
track();
|
||||
uint8_t sha[32];
|
||||
if (!tls.getFingerprintSHA256(sha)) {
|
||||
page.error = "No certificate from " + u.host;
|
||||
return;
|
||||
}
|
||||
std::string presented = hex(sha, sizeof sha), pinned;
|
||||
std::string key = pinKey(u.host, u.portOrDefault());
|
||||
if (!store_.getString(key.c_str(), pinned)) store_.putString(key.c_str(), presented); // first use
|
||||
else if (pinned != presented) {
|
||||
page.outcome = GeminiPage::Outcome::CertificateChanged;
|
||||
page.host = u.host;
|
||||
page.port = u.portOrDefault();
|
||||
page.pinned = pinned;
|
||||
page.presented = presented;
|
||||
page.error = "The certificate of " + u.host + " changed";
|
||||
return;
|
||||
}
|
||||
|
||||
tls.print((gemini::requestUrl(url) + "\r\n").c_str());
|
||||
std::string header;
|
||||
bool inBody = false;
|
||||
uint32_t last = millis();
|
||||
char buf[512];
|
||||
while (!cancel_) {
|
||||
int n = tls.available() ? tls.read(reinterpret_cast<uint8_t*>(buf), sizeof buf) : 0;
|
||||
if (n <= 0) {
|
||||
if (!tls.connected()) break;
|
||||
if (millis() - last > kIdleTimeoutMs) {
|
||||
page.error = "Timed out";
|
||||
return;
|
||||
}
|
||||
delay(5);
|
||||
continue;
|
||||
}
|
||||
last = millis();
|
||||
int i = 0;
|
||||
while (!inBody && i < n) {
|
||||
char c = buf[i++];
|
||||
if (c == '\n') inBody = true;
|
||||
else if (c != '\r') header += c;
|
||||
if (header.size() > 1100) {
|
||||
page.error = "Malformed response header";
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!inBody) continue;
|
||||
if (!gemini::parseHeader(header, page.header)) {
|
||||
page.error = "Malformed response header";
|
||||
return;
|
||||
}
|
||||
if (page.header.category() != gemini::Category::Success) break; // no body to read
|
||||
// With a card, the body goes there while the connection is open (only 4 KB in RAM),
|
||||
// and comes back into RAM once the connection's memory is free again.
|
||||
if (storage_.state().present) {
|
||||
slotUrl_[slot].clear();
|
||||
if (!receiveToCard(tls, buf + i, n - i, page, slot)) return;
|
||||
tls.stop();
|
||||
slotUrl_[slot] = url;
|
||||
if (loadIntoRam) loadFromCard(page, freeBefore, slotPath(slot));
|
||||
page.outcome = GeminiPage::Outcome::Ok;
|
||||
return;
|
||||
}
|
||||
// Q86, two floors: once the connection closes, its ~50 KB comes back, so the page may use
|
||||
// what was free before minus the steady floor; meanwhile the heap must stay above the
|
||||
// transient floor. Both counted with the next 4 KB chunk and the line index.
|
||||
size_t take = std::min<size_t>(n - i, kMaxBody - page.text.bytes());
|
||||
size_t pageCost = page.text.bytes() + page.text.lineCount() * 8 + TextBuffer::kChunk + take;
|
||||
if (take < static_cast<size_t>(n - i)) page.truncatedWhy = "longer than 64 KB";
|
||||
else if (freeBefore < kSteadyFloor + pageCost) page.truncatedWhy = "not enough memory to keep it";
|
||||
else if (esp_get_free_heap_size() < kTransientFloor + TextBuffer::kChunk + take)
|
||||
page.truncatedWhy = "not enough memory while receiving";
|
||||
if (!page.truncatedWhy.empty()) {
|
||||
page.truncated = true;
|
||||
break;
|
||||
}
|
||||
page.text.append(buf + i, take);
|
||||
track();
|
||||
}
|
||||
if (cancel_) {
|
||||
page.error = "Cancelled";
|
||||
return;
|
||||
}
|
||||
if (!inBody && !gemini::parseHeader(header, page.header)) { // a header with no line end
|
||||
page.error = header.empty() ? "Empty response" : "Malformed response header";
|
||||
return;
|
||||
}
|
||||
page.text.finish();
|
||||
page.outcome = GeminiPage::Outcome::Ok;
|
||||
}
|
||||
|
||||
// Streams the rest of the body to a cache file in 1 KB pieces, each written by the storage task:
|
||||
// with IRC's TLS connection and this one both open, every KB of RAM counts (Q86).
|
||||
|
||||
bool GeminiService::receiveToCard(NetworkClientSecure& tls, const char* first, size_t len, GeminiPage& page, int slot) {
|
||||
struct Cache {
|
||||
File file;
|
||||
};
|
||||
auto cache = std::make_shared<Cache>();
|
||||
bool opened = storage_.runAndWait([cache, slot]() {
|
||||
SD.mkdir("/gemini");
|
||||
SD.mkdir("/gemini/cache");
|
||||
cache->file = SD.open(slotPath(slot), FILE_WRITE);
|
||||
});
|
||||
if (!opened || !cache->file) {
|
||||
page.error = "Can't write to the SD card";
|
||||
return false;
|
||||
}
|
||||
std::string pending(first, len);
|
||||
constexpr size_t kPiece = 1024;
|
||||
pending.reserve(kPiece + 512);
|
||||
bool ok = true;
|
||||
auto flush = [&]() {
|
||||
if (pending.empty() || !ok) return;
|
||||
ok = storage_.runAndWait([cache, &pending, &ok]() {
|
||||
ok = cache->file.write(reinterpret_cast<const uint8_t*>(pending.data()), pending.size()) == pending.size();
|
||||
}) && ok;
|
||||
page.receivedBytes += pending.size();
|
||||
pending.clear();
|
||||
};
|
||||
uint32_t last = millis();
|
||||
char buf[512];
|
||||
while (!cancel_ && ok) {
|
||||
if (pending.size() >= kPiece) flush();
|
||||
if (page.receivedBytes + pending.size() >= kMaxOnCard) {
|
||||
page.truncated = true;
|
||||
page.truncatedWhy = "longer than 1 MB";
|
||||
break;
|
||||
}
|
||||
int n = tls.available() ? tls.read(reinterpret_cast<uint8_t*>(buf), sizeof buf) : 0;
|
||||
if (n <= 0) {
|
||||
if (!tls.connected()) break;
|
||||
if (millis() - last > kIdleTimeoutMs) break; // keep what came
|
||||
delay(5);
|
||||
continue;
|
||||
}
|
||||
last = millis();
|
||||
pending.append(buf, n);
|
||||
lowest_ = std::min<size_t>(lowest_, esp_get_free_heap_size());
|
||||
}
|
||||
flush();
|
||||
storage_.runAndWait([cache]() { cache->file.close(); });
|
||||
if (cancel_) page.error = "Cancelled";
|
||||
else if (!ok) page.error = "Writing to the SD card failed";
|
||||
else page.cachePath = slotPath(slot);
|
||||
return !cancel_ && ok;
|
||||
}
|
||||
|
||||
// Back into RAM, now that the connection's memory is free again: one pass over the file counts
|
||||
// its lines and indexes every kIndexEvery-th, and loads lines while the steady floor allows (Q86).
|
||||
// If they don't all fit, the page is windowed: the App asks for other windows as you scroll.
|
||||
void GeminiService::loadFromCard(GeminiPage& page, size_t freeBefore, const std::string& path) {
|
||||
size_t budget = freeBefore > kSteadyFloor + TextBuffer::kChunk ? freeBefore - kSteadyFloor - TextBuffer::kChunk : 0;
|
||||
budget = std::min(budget, kMaxBody);
|
||||
page.backing = path;
|
||||
page.lineIndex.clear();
|
||||
page.totalLines = 0;
|
||||
page.windowStart = 0;
|
||||
bool loading = true;
|
||||
storage_.runAndWait([&]() {
|
||||
File f = SD.open(path.c_str());
|
||||
if (!f) return;
|
||||
std::string line;
|
||||
uint32_t offset = 0, lineStart = 0;
|
||||
bool pre = false;
|
||||
auto endLine = [&]() {
|
||||
if (page.totalLines % kIndexEvery == 0) page.lineIndex.push_back(lineStart | (pre ? 0x80000000u : 0));
|
||||
if (line.compare(0, 3, "```") == 0) pre = !pre;
|
||||
if (loading) {
|
||||
if (page.text.bytes() + page.text.lineCount() * 8 + line.size() + 1 > budget) loading = false;
|
||||
else {
|
||||
line += '\n';
|
||||
page.text.append(line.data(), line.size());
|
||||
}
|
||||
}
|
||||
page.totalLines++;
|
||||
line.clear();
|
||||
};
|
||||
char buf[512];
|
||||
int n;
|
||||
while ((n = f.read(reinterpret_cast<uint8_t*>(buf), sizeof buf)) > 0) {
|
||||
for (int i = 0; i < n; i++, offset++) {
|
||||
if (buf[i] == '\n') {
|
||||
endLine();
|
||||
lineStart = offset + 1;
|
||||
} else if (line.size() < kMaxLineBytes) line += buf[i];
|
||||
}
|
||||
}
|
||||
if (!line.empty()) endLine();
|
||||
f.close();
|
||||
});
|
||||
page.text.finish();
|
||||
page.windowed = !loading;
|
||||
if (page.windowed)
|
||||
console.printf("gemini: %s is windowed: %u of %u lines in memory\n", path.c_str(),
|
||||
(unsigned)page.text.lineCount(), (unsigned)page.totalLines);
|
||||
}
|
||||
|
||||
// Lines from `firstLine` on, from the page's file, as many as `budget` allows.
|
||||
void GeminiService::readWindow(GeminiPage& page, size_t firstLine, size_t budget) {
|
||||
size_t entry = firstLine / kIndexEvery;
|
||||
if (entry >= page.lineIndex.size()) return;
|
||||
storage_.runAndWait([&]() {
|
||||
File f = SD.open(page.backing.c_str());
|
||||
if (!f || !f.seek(page.lineIndex[entry] & 0x7FFFFFFFu)) return;
|
||||
size_t lineNo = entry * kIndexEvery;
|
||||
std::string line;
|
||||
char buf[512];
|
||||
int n;
|
||||
bool done = false;
|
||||
while (!done && (n = f.read(reinterpret_cast<uint8_t*>(buf), sizeof buf)) > 0) {
|
||||
for (int i = 0; i < n && !done; i++) {
|
||||
if (buf[i] != '\n') {
|
||||
if (line.size() < kMaxLineBytes) line += buf[i];
|
||||
continue;
|
||||
}
|
||||
if (lineNo >= firstLine) {
|
||||
if (page.text.bytes() + page.text.lineCount() * 8 + line.size() + 1 > budget) done = true;
|
||||
else {
|
||||
line += '\n';
|
||||
page.text.append(line.data(), line.size());
|
||||
}
|
||||
}
|
||||
lineNo++;
|
||||
line.clear();
|
||||
}
|
||||
}
|
||||
if (!done && !line.empty() && lineNo >= firstLine) page.text.append(line.data(), line.size());
|
||||
f.close();
|
||||
});
|
||||
page.text.finish();
|
||||
}
|
||||
|
||||
void GeminiService::report(const GeminiPage& page, size_t heapBefore, size_t heapLowest) {
|
||||
console.printf("gemini: %s -> %s, %lu ms\n", page.requested.c_str(), page.url.c_str(), (unsigned long)page.ms);
|
||||
if (page.outcome != GeminiPage::Outcome::Ok) console.printf("gemini: %s\n", page.error.c_str());
|
||||
else console.printf("gemini: %d %s, %u bytes, %u lines%s\n", page.header.status, page.header.meta.c_str(),
|
||||
(unsigned)page.text.bytes(), (unsigned)page.text.lineCount(),
|
||||
page.truncated ? (" (truncated: " + page.truncatedWhy + ")").c_str() : "");
|
||||
if (page.outcome == GeminiPage::Outcome::CertificateChanged)
|
||||
console.printf("gemini: pinned %s\ngemini: now %s\n", page.pinned.c_str(), page.presented.c_str());
|
||||
for (size_t i = 0; i < page.text.lineCount() && i < 6; i++) console.printf(" %s\n", page.text.line(i).substr(0, 100).c_str());
|
||||
if (!page.cachePath.empty())
|
||||
console.printf("gemini: %u bytes on the card in %s\n", (unsigned)page.receivedBytes, page.cachePath.c_str());
|
||||
console.printf("gemini: heap %u before, %u lowest seen, %u after\n", (unsigned)heapBefore, (unsigned)heapLowest,
|
||||
(unsigned)esp_get_free_heap_size());
|
||||
}
|
||||
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,143 @@
|
||||
#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
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <sd_diskio.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
|
||||
#include "platform/pins.h"
|
||||
@@ -82,6 +83,30 @@ void StorageService::runJob(std::function<void()> job) {
|
||||
if (task_) xTaskNotifyGive(task_);
|
||||
}
|
||||
|
||||
bool StorageService::runAndWait(std::function<void()> job) {
|
||||
// Shared with the job, which outlives this wait if the card is missing and it never starts.
|
||||
// Exactly one side wins the state change: the job (Queued -> Running) or the wait giving up
|
||||
// (Queued -> Abandoned), so an abandoned job can never touch this stack frame.
|
||||
enum : int { Queued, Running, Abandoned };
|
||||
struct Run {
|
||||
std::atomic<int> state{Queued};
|
||||
SemaphoreHandle_t done = xSemaphoreCreateBinary();
|
||||
~Run() { vSemaphoreDelete(done); }
|
||||
};
|
||||
auto run = std::make_shared<Run>();
|
||||
runJob([run, job]() {
|
||||
int expected = Queued;
|
||||
if (!run->state.compare_exchange_strong(expected, Running)) return;
|
||||
job();
|
||||
xSemaphoreGive(run->done);
|
||||
});
|
||||
for (int waited = 0; xSemaphoreTake(run->done, pdMS_TO_TICKS(500)) != pdTRUE; waited += 500) {
|
||||
int expected = Queued;
|
||||
if (waited >= 5000 && run->state.compare_exchange_strong(expected, Abandoned)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool StorageService::requestFormat() {
|
||||
if (formatRequested_ || !task_) return false;
|
||||
formatRequested_ = true;
|
||||
|
||||
@@ -42,6 +42,9 @@ class StorageService : public Service {
|
||||
|
||||
// Runs `job` on the storage task, where card access is safe (e.g. reading an Update File).
|
||||
void runJob(std::function<void()> job);
|
||||
// The same, and waits for it (from another task, never the storage task itself). False if it
|
||||
// never started within 5 s: no card mounted, and then it never will run.
|
||||
bool runAndWait(std::function<void()> job);
|
||||
|
||||
// Erases the whole card: one partition spanning the card, formatted FAT32.
|
||||
bool requestFormat();
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "gemini_response.h"
|
||||
#include "gemini_url.h"
|
||||
#include "gemtext.h"
|
||||
#include "text_buffer.h"
|
||||
|
||||
using namespace roro::gemini;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
static void resolvesTo(const char* ref, const char* expected) {
|
||||
TEST_ASSERT_EQUAL_STRING_MESSAGE(expected, resolve("gemini://a/b/c/d;p?q", ref).c_str(), ref);
|
||||
}
|
||||
|
||||
// RFC 3986, section 5.4.1, with gemini:// for http://.
|
||||
void test_rfc3986_normal_examples() {
|
||||
resolvesTo("g:h", "g:h");
|
||||
resolvesTo("g", "gemini://a/b/c/g");
|
||||
resolvesTo("./g", "gemini://a/b/c/g");
|
||||
resolvesTo("g/", "gemini://a/b/c/g/");
|
||||
resolvesTo("/g", "gemini://a/g");
|
||||
resolvesTo("//g", "gemini://g");
|
||||
resolvesTo("?y", "gemini://a/b/c/d;p?y");
|
||||
resolvesTo("g?y", "gemini://a/b/c/g?y");
|
||||
resolvesTo("#s", "gemini://a/b/c/d;p?q#s");
|
||||
resolvesTo("g#s", "gemini://a/b/c/g#s");
|
||||
resolvesTo("g?y#s", "gemini://a/b/c/g?y#s");
|
||||
resolvesTo(";x", "gemini://a/b/c/;x");
|
||||
resolvesTo("", "gemini://a/b/c/d;p?q");
|
||||
resolvesTo(".", "gemini://a/b/c/");
|
||||
resolvesTo("./", "gemini://a/b/c/");
|
||||
resolvesTo("..", "gemini://a/b/");
|
||||
resolvesTo("../", "gemini://a/b/");
|
||||
resolvesTo("../g", "gemini://a/b/g");
|
||||
resolvesTo("../..", "gemini://a/");
|
||||
resolvesTo("../../", "gemini://a/");
|
||||
resolvesTo("../../g", "gemini://a/g");
|
||||
}
|
||||
|
||||
// Section 5.4.2.
|
||||
void test_rfc3986_abnormal_examples() {
|
||||
resolvesTo("../../../g", "gemini://a/g");
|
||||
resolvesTo("../../../../g", "gemini://a/g");
|
||||
resolvesTo("/./g", "gemini://a/g");
|
||||
resolvesTo("/../g", "gemini://a/g");
|
||||
resolvesTo("g.", "gemini://a/b/c/g.");
|
||||
resolvesTo(".g", "gemini://a/b/c/.g");
|
||||
resolvesTo("./../g", "gemini://a/b/g");
|
||||
resolvesTo("g/./h", "gemini://a/b/c/g/h");
|
||||
resolvesTo("g/../h", "gemini://a/b/c/h");
|
||||
resolvesTo("g;x=1/./y", "gemini://a/b/c/g;x=1/y");
|
||||
resolvesTo("g;x=1/../y", "gemini://a/b/c/y");
|
||||
}
|
||||
|
||||
void test_parse_a_gemini_url() {
|
||||
Url u;
|
||||
TEST_ASSERT_TRUE(parseUrl("gemini://Example.ORG:1966/a/b?c=d#e", u));
|
||||
TEST_ASSERT_EQUAL_STRING("gemini", u.scheme.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("example.org", u.host.c_str()); // hosts are case-insensitive
|
||||
TEST_ASSERT_EQUAL(1966, u.port);
|
||||
TEST_ASSERT_EQUAL_STRING("/a/b", u.path.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("c=d", u.query.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("e", u.fragment.c_str());
|
||||
TEST_ASSERT_TRUE(parseUrl("gemini://example.org", u));
|
||||
TEST_ASSERT_EQUAL(1965, u.portOrDefault());
|
||||
TEST_ASSERT_FALSE(parseUrl("not a url", u));
|
||||
}
|
||||
|
||||
void test_the_request_drops_the_fragment_and_never_has_an_empty_path() {
|
||||
TEST_ASSERT_EQUAL_STRING("gemini://example.org/", requestUrl("gemini://example.org").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("gemini://example.org/a?b", requestUrl("gemini://example.org/a?b#frag").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("gemini://example.org:1966/", requestUrl("gemini://EXAMPLE.org:1966").c_str());
|
||||
}
|
||||
|
||||
void test_other_schemes_are_recognised() {
|
||||
TEST_ASSERT_TRUE(isGemini("gemini://a/"));
|
||||
TEST_ASSERT_FALSE(isGemini("https://a/"));
|
||||
TEST_ASSERT_FALSE(isGemini("gopher://a/"));
|
||||
TEST_ASSERT_FALSE(isGemini("mailto:someone@example.org"));
|
||||
}
|
||||
|
||||
void test_query_encoding() {
|
||||
TEST_ASSERT_EQUAL_STRING("hello%20world", encodeQuery("hello world").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("caf%C3%A9%3F%26", encodeQuery("caf\xC3\xA9?&").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("a-b_c.d~e", encodeQuery("a-b_c.d~e").c_str());
|
||||
}
|
||||
|
||||
void test_response_header() {
|
||||
Header h;
|
||||
TEST_ASSERT_TRUE(parseHeader("20 text/gemini; charset=utf-8; lang=en", h));
|
||||
TEST_ASSERT_EQUAL(20, h.status);
|
||||
TEST_ASSERT_EQUAL_STRING("text/gemini", h.mimeType().c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("utf-8", h.parameter("charset").c_str());
|
||||
TEST_ASSERT_TRUE(h.category() == Category::Success);
|
||||
TEST_ASSERT_TRUE(parseHeader("20", h)); // no MIME type: text/gemini by default
|
||||
TEST_ASSERT_EQUAL_STRING("text/gemini", h.mimeType().c_str());
|
||||
TEST_ASSERT_TRUE(parseHeader("31 gemini://cosmos.skyjake.fi/", h));
|
||||
TEST_ASSERT_TRUE(h.category() == Category::Redirect);
|
||||
TEST_ASSERT_EQUAL_STRING("gemini://cosmos.skyjake.fi/", h.meta.c_str());
|
||||
TEST_ASSERT_TRUE(parseHeader("11 Password", h));
|
||||
TEST_ASSERT_TRUE(h.category() == Category::Input);
|
||||
TEST_ASSERT_TRUE(h.sensitiveInput());
|
||||
TEST_ASSERT_TRUE(parseHeader("51 Not found", h));
|
||||
TEST_ASSERT_TRUE(h.category() == Category::PermanentFailure);
|
||||
TEST_ASSERT_TRUE(parseHeader("60 Certificate required", h));
|
||||
TEST_ASSERT_TRUE(h.category() == Category::ClientCertificate);
|
||||
}
|
||||
|
||||
void test_malformed_headers_are_refused() {
|
||||
Header h;
|
||||
TEST_ASSERT_FALSE(parseHeader("", h));
|
||||
TEST_ASSERT_FALSE(parseHeader("2 text/gemini", h));
|
||||
TEST_ASSERT_FALSE(parseHeader("ab text/gemini", h));
|
||||
TEST_ASSERT_FALSE(parseHeader("20text/gemini", h));
|
||||
TEST_ASSERT_FALSE(parseHeader("20 " + std::string(1025, 'x'), h));
|
||||
}
|
||||
|
||||
void test_gemtext_line_types() {
|
||||
auto lines = parseGemtext(
|
||||
"# Title\n## Sub\n### Subsub\nPlain text\n* item\n> quoted\n=> gemini://a/b Label here\n=>/rel\n"
|
||||
"```ascii art\n# not a heading\n=> not a link\n```\nafter\n");
|
||||
TEST_ASSERT_EQUAL(13, static_cast<int>(lines.size()));
|
||||
TEST_ASSERT_TRUE(lines[0].type == LineType::Heading1);
|
||||
TEST_ASSERT_EQUAL_STRING("Title", lines[0].text.c_str());
|
||||
TEST_ASSERT_TRUE(lines[1].type == LineType::Heading2);
|
||||
TEST_ASSERT_TRUE(lines[2].type == LineType::Heading3);
|
||||
TEST_ASSERT_TRUE(lines[3].type == LineType::Text);
|
||||
TEST_ASSERT_TRUE(lines[4].type == LineType::ListItem);
|
||||
TEST_ASSERT_EQUAL_STRING("item", lines[4].text.c_str());
|
||||
TEST_ASSERT_TRUE(lines[5].type == LineType::Quote);
|
||||
TEST_ASSERT_EQUAL_STRING("quoted", lines[5].text.c_str());
|
||||
TEST_ASSERT_TRUE(lines[6].type == LineType::Link);
|
||||
TEST_ASSERT_EQUAL_STRING("gemini://a/b", lines[6].url.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("Label here", lines[6].text.c_str());
|
||||
TEST_ASSERT_TRUE(lines[7].type == LineType::Link);
|
||||
TEST_ASSERT_EQUAL_STRING("/rel", lines[7].url.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("/rel", lines[7].text.c_str()); // no label: the URL is the label
|
||||
TEST_ASSERT_TRUE(lines[8].type == LineType::PreToggle);
|
||||
TEST_ASSERT_EQUAL_STRING("ascii art", lines[8].text.c_str());
|
||||
TEST_ASSERT_TRUE(lines[9].type == LineType::Preformatted);
|
||||
TEST_ASSERT_EQUAL_STRING("# not a heading", lines[9].text.c_str());
|
||||
TEST_ASSERT_TRUE(lines[10].type == LineType::Preformatted);
|
||||
TEST_ASSERT_TRUE(lines[11].type == LineType::PreToggle);
|
||||
TEST_ASSERT_TRUE(lines[12].type == LineType::Text);
|
||||
}
|
||||
|
||||
void test_gemtext_handles_crlf_and_a_missing_last_newline() {
|
||||
auto lines = parseGemtext("one\r\n=>\tgemini://x/\t spaced label \r\ntwo");
|
||||
TEST_ASSERT_EQUAL(3, static_cast<int>(lines.size()));
|
||||
TEST_ASSERT_EQUAL_STRING("one", lines[0].text.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("gemini://x/", lines[1].url.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("spaced label", lines[1].text.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("two", lines[2].text.c_str());
|
||||
}
|
||||
|
||||
void test_display_text_keeps_latin1_and_replaces_the_rest() {
|
||||
TEST_ASSERT_EQUAL_STRING("caf\xC3\xA9", displayText("caf\xC3\xA9").c_str()); // é stays
|
||||
TEST_ASSERT_EQUAL_STRING("? and ?", displayText("\xE2\x9C\x93 and \xF0\x9F\x99\x82").c_str()); // ✓ and 🙂
|
||||
TEST_ASSERT_EQUAL_STRING("bad ? byte", displayText("bad \xFF byte").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("a b", displayText("a\tb").c_str()); // tab: three spaces here
|
||||
}
|
||||
|
||||
void test_saved_page_paths() {
|
||||
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/index.gmi", savedPath("gemini://example.org/").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/index.gmi", savedPath("gemini://example.org").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/log/2026/post.gmi",
|
||||
savedPath("gemini://example.org/log/2026/post.gmi").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/log/index.gmi", savedPath("gemini://example.org/log/").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/about.gmi", savedPath("gemini://example.org/about").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org_1966/a~q_x.gmi",
|
||||
savedPath("gemini://example.org:1966/a?q:x#frag").c_str());
|
||||
}
|
||||
|
||||
void test_text_buffer_keeps_lines_across_any_feed() {
|
||||
TextBuffer b;
|
||||
std::string doc = "# Title\r\nline two\n\nlast without end";
|
||||
for (size_t i = 0; i < doc.size(); i += 3) b.append(doc.data() + i, std::min<size_t>(3, doc.size() - i));
|
||||
b.finish();
|
||||
TEST_ASSERT_EQUAL(4, static_cast<int>(b.lineCount()));
|
||||
TEST_ASSERT_EQUAL_STRING("# Title", b.line(0).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("line two", b.line(1).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", b.line(2).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("last without end", b.line(3).c_str());
|
||||
TEST_ASSERT_EQUAL(doc.size(), b.bytes());
|
||||
}
|
||||
|
||||
void test_text_buffer_spreads_over_chunks_without_splitting_lines() {
|
||||
TextBuffer b;
|
||||
std::string line(1000, 'x');
|
||||
for (int i = 0; i < 20; i++) {
|
||||
std::string l = std::to_string(i) + line + "\n";
|
||||
b.append(l.data(), l.size());
|
||||
}
|
||||
std::string big(6000, 'y'); // longer than a chunk
|
||||
big += "\n";
|
||||
b.append(big.data(), big.size());
|
||||
TEST_ASSERT_EQUAL(21, static_cast<int>(b.lineCount()));
|
||||
for (int i = 0; i < 20; i++) TEST_ASSERT_EQUAL_STRING((std::to_string(i) + line).c_str(), b.line(i).c_str());
|
||||
TEST_ASSERT_EQUAL(6000, static_cast<int>(b.line(20).size()));
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_rfc3986_normal_examples);
|
||||
RUN_TEST(test_rfc3986_abnormal_examples);
|
||||
RUN_TEST(test_parse_a_gemini_url);
|
||||
RUN_TEST(test_the_request_drops_the_fragment_and_never_has_an_empty_path);
|
||||
RUN_TEST(test_other_schemes_are_recognised);
|
||||
RUN_TEST(test_query_encoding);
|
||||
RUN_TEST(test_response_header);
|
||||
RUN_TEST(test_malformed_headers_are_refused);
|
||||
RUN_TEST(test_gemtext_line_types);
|
||||
RUN_TEST(test_gemtext_handles_crlf_and_a_missing_last_newline);
|
||||
RUN_TEST(test_display_text_keeps_latin1_and_replaces_the_rest);
|
||||
RUN_TEST(test_saved_page_paths);
|
||||
RUN_TEST(test_text_buffer_keeps_lines_across_any_feed);
|
||||
RUN_TEST(test_text_buffer_spreads_over_chunks_without_splitting_lines);
|
||||
return UNITY_END();
|
||||
}
|
||||
Reference in New Issue
Block a user