Compare commits

...
Author SHA1 Message Date
twisla f0306dd880 Merge pull request 'Storage: share the card with a phone's browser (#88)' (#89) from web-files into main
Site / build (push) Successful in 13s
CI / build (push) Successful in 2m45s
2026-10-07 22:37:11 +00:00
twislaandClaude Opus 5.5 e3fe618c7f Devlog: "Press w" is v0.18.0
CI / build (pull_request) Successful in 1m25s
Site / build (pull_request) Successful in 9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 00:35:15 +02:00
twislaandClaude Opus 5.5 d7092ee6d6 F1, devlog: the QR code scanned with a real phone (#88)
CI / build (pull_request) Successful in 1m31s
Site / build (pull_request) Successful in 9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 00:33:14 +02:00
twislaandClaude Opus 5.5 63c2da8138 Devlog: "Press w" with the phone's screenshot and a photo of the device (#88)
CI / build (pull_request) Successful in 1m32s
Site / build (pull_request) Successful in 15s
Both pictures were resized and saved again from their pixels alone: no
metadata is carried over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 00:30:50 +02:00
twislaandClaude Opus 5.5 057af773e4 Devlog: "Press w", the SD card in a phone's browser (#88)
CI / build (pull_request) Successful in 1m39s
Site / build (pull_request) Successful in 15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 00:26:43 +02:00
twislaandClaude Opus 5.5 6b02cd3d5f Storage: share the card with a phone's browser (#88)
CI / build (pull_request) Successful in 1m51s
Site / build (pull_request) Successful in 16s
`w` in the Storage App starts a small HTTP server and shows its address,
as a QR code and in letters, with a six-digit code. A browser on the same
network that has typed the code can list, download, upload, make folders
and delete, under the Storage App's rules. The server runs only while that
screen is open. Nothing is encrypted, and the screen says so.

Uploads are streamed to the card under a temporary name and renamed when
whole. Every access to the card is handed to the storage task, 8 KB at a
time, from the server's own task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 00:11:55 +02:00
twisla c35bc47693 Merge pull request 'Screenshots: Fn+p on every screen (#83)' (#86) from screenshot-key into main
Site / build (push) Successful in 13s
CI / build (push) Successful in 2m49s
2026-10-07 19:42:11 +00:00
twislaandClaude Opus 5.5 4cdb4c342f Screenshots: Fn+p on every screen (#83)
CI / build (pull_request) Successful in 1m44s
Site / build (pull_request) Successful in 9s
Fn+p saves the screen as it is to /screenshots, as the Shell's `screenshot`
does, from anywhere: text fields, dialogs and the help panel included. The
key never reaches an App.

It refuses on Settings > Debug Console, which shows the token: a picture of
that page is a copy of the token in a file (App::showsSecret). `key shot`
presses it over the consoles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 21:39:16 +02:00
twisla 1c9f90f92e Merge pull request 'Storage: view pictures, PNG, JPEG, BMP and GIF (#45)' (#85) from storage-images into main
Site / build (push) Successful in 20s
CI / build (push) Successful in 2m54s
2026-10-07 19:25:12 +00:00
33 changed files with 1219 additions and 15 deletions
+3 -1
View File
@@ -136,6 +136,8 @@ The Storage App (docs/milestones/F1.md) shows what's on the SD card: each folder
A copy runs in the background of the card (about 400 KB a second) in short turns, so Logs and Captures keep being written; it shows its progress, Back cancels it and takes back what was copied, and each file's size is checked afterwards. Three things can't be changed: the top-level folders the firmware keeps its files in (what's inside them can), `/gemini/cache`, and any file being written right now (today's IRC Logs, a Track or a Capture being recorded). The App says why when it refuses. A folder with more than 256 entries shows the first 256 by name and says so. A copy runs in the background of the card (about 400 KB a second) in short turns, so Logs and Captures keep being written; it shows its progress, Back cancels it and takes back what was copied, and each file's size is checked afterwards. Three things can't be changed: the top-level folders the firmware keeps its files in (what's inside them can), `/gemini/cache`, and any file being written right now (today's IRC Logs, a Track or a Capture being recorded). The App says why when it refuses. A folder with more than 256 entries shows the first 256 by name and says so.
**`w` shares the card with a browser on the same network** (issue #88): a small HTTP server and one page, for a phone with nothing to install. The screen shows the address as a QR code and a six-digit code, new each time; whoever has typed it can list, download, upload (streamed to the card under a temporary name), make folders and delete, under the Storage App's rules. It runs only while that screen is open, takes one request at a time, moves about 200 KB a second, and is not encrypted. It costs 57 KB of flash, and 13 KB of memory while it is on.
Enter on a file opens it by type; Tab switches to the same file as a hex dump or as text: Enter on a file opens it by type; Tab switches to the same file as a hex dump or as text:
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only the screen's worth is read from the card, so a file of any size opens at once. Logs open at the end. Up and Down move a line, Left and Right a page, `t` and `b` go to the top and the end, `e` edits it, whatever its size (see Notes). - **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only the screen's worth is read from the card, so a file of any size opens at once. Logs open at the end. Up and Down move a line, Left and Right a page, `t` and `b` go to the top and the end, `e` edits it, whatever its size (see Notes).
@@ -168,7 +170,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
| Command | Effect | | Command | Effect |
|---|---| |---|---|
| `burst` | Publishes 5 Notifications at once | | `burst` | Publishes 5 Notifications at once |
| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` | | `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help\|shot`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing; `shot` is Fn+p: a screenshot). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` |
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) | | `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
+43
View File
@@ -290,3 +290,46 @@ Test pictures were copied to a scratch folder and removed afterwards, with the t
**A decoder that worked once.** The library's PNG decoder showed the first picture and refused the next five: "no memory". It wants 44 KB in one piece, and after some use the largest piece is 43 to 47 KB. Writing a decoder that needs 32 KB was less work than it sounds, and unlike the library's it has tests. **A decoder that worked once.** The library's PNG decoder showed the first picture and refused the next five: "no memory". It wants 44 KB in one piece, and after some use the largest piece is 43 to 47 KB. Writing a decoder that needs 32 KB was less work than it sounds, and unlike the library's it has tests.
**Two sentences still said "up to 16 KB"** about editing, in the README and the Storage guide, after issue #47 lifted that. Corrected here. **Two sentences still said "up to 16 KB"** about editing, in the README and the Storage guide, after issue #47 lifted that. Corrected here.
## Sharing the card with a browser (issue #88)
Files reached the card through the Debug Console's `put` and `get`, or by taking the card out. A phone has neither.
### Decisions (2026-10-07; the recommendation was accepted as it stood, without a round of questions)
- **A web page, not FTP, SFTP or WebDAV.** A browser is the only client every phone has. FTP and WebDAV need an app there; SFTP needs a whole SSH server here. WebDAV can come later on the same server, for computers.
- **Off unless asked for:** `w` in the Storage App opens a "Share" screen, and the server runs only while that screen is open.
- **A six-digit code on the screen**, new each time, typed in the page; the address is also a QR code, which carries the code. Five wrong codes close it for a minute (the Debug Console's `AuthGate`). A browser that got it right holds a cookie; starting again puts every browser out.
- **Not encrypted.** A TLS server costs about 40 KB of memory a connection. The screen says so.
- **The Storage App's rules** (`whyReadOnly`): the firmware's own folders, and files in use.
### As built
- **`WebShare`** (`src/services/web_share`): ESP-IDF's HTTP server, which is in the framework already. Seven requests: the page, the code, a listing as JSON, a download, an upload, a new folder, a delete.
- **Every access to the card is handed to the storage task**, 8 KB at a time, from the server's own task: a download and an upload are loops of "one piece from the card, one piece to the network".
- **An upload is the request's body**, as the browser's `PUT` sends it: no form to take apart. It goes to `<name>.part` and is renamed when the last byte has come; anything less is removed. A file that exists is refused unless the page asked, after asking the user.
- **The page** (`web_share_page.h`) is one file of 5.4 KB with its style and script in it, served from flash.
- **`lib/files/src/share_rules.h`** (host-tested, 5 tests): what a request names, which paths a browser may ask for (from the root, no `..`), the JSON, the code and the cookie.
- **Cost:** 57 KB of flash, most of it the server. 13 KB of memory while sharing (108.4 KB free before, 95.4 with the screen open), given back on leaving.
### Checks on the device (2026-10-07 and 08)
A scratch folder was used and removed.
| Check | Result |
|---|---|
| `w` | The QR code, the address and the code; `share: on` on the console |
| The page, and a listing without the code | 200; 401 |
| A wrong code, the right one (typed `825 132`) | 403; in |
| Upload, 2.6 MB | 11 to 17 s (150 to 230 KB/s); downloaded again and compared: the same, byte for byte |
| The same name again; with "replace" | 409; replaced |
| `..` in a path; deleting `/notes`; deleting a folder that isn't empty | 400; 403 "The firmware keeps its files in /notes"; 403 |
| Five wrong codes | The fifth and every one after: 429, the right code too. A browser that was in stays in |
| In Chromium at a phone's width | The scanned address logs in by itself; two files uploaded, one downloaded and compared, a folder made, a file deleted after asking, a replacement after asking, the refusal shown. No sideways scroll |
| Back | The server is gone (connection refused), memory is back |
**Found on the way:** the server answers one request at a time. A second request during a slow download waited until it had ended. It is said in the guide, and not changed.
**On a real phone** (the maintainer's, 2026-10-08): the QR code, scanned with the phone's camera, opens the page and logs in; the page lists the card, in the phone's dark theme.
**Not checked:** Safari. A card pulled during a transfer. Sharing with IRC connected, when memory is shorter. Home, and the screen turning off, while sharing (the code stops the server on leaving the App; only Back was tried).
+15
View File
@@ -44,3 +44,18 @@ The lists first lived in each App's `help()`, as code. They are now **data, in o
Three rows lost their second wording on the way, since a table is constant: GNSS's `Tab` and `r`, and the Scanner's `c`, now say both things they do ("record a Track, or stop it") instead of the one that applies. The guide pages keep their written tables too, where they say more than a key list can; those can still drift, and the generated ones under them are the reference. Three rows lost their second wording on the way, since a table is constant: GNSS's `Tab` and `r`, and the Scanner's `c`, now say both things they do ("record a Track, or stop it") instead of the one that applies. The guide pages keep their written tables too, where they say more than a key list can; those can still drift, and the generated ones under them are the reference.
**Not checked:** the real Fn+h and `?` on the keyboard (the mapper is host-tested; the device was driven with `key help`); the Setup screens, which only a device that was never set up shows, so their new text has not been seen on a screen; and the states that need something to happen first (a dialog, a copy in progress, a Gemini prompt, a packet's details): their lists were read against the key handling, not looked at. **Not checked:** the real Fn+h and `?` on the keyboard (the mapper is host-tested; the device was driven with `key help`); the Setup screens, which only a device that was never set up shows, so their new text has not been seen on a screen; and the states that need something to happen first (a dialog, a copy in progress, a Gemini prompt, a packet's details): their lists were read against the key handling, not looked at.
## The screenshot key (issue #83)
A screenshot could only be taken by typing `screenshot` in the Shell, where "now" is a picture of the Shell.
- **Fn+p, on every screen**, text fields included, saves the screen as it is (a dialog, the help panel or a Toast if one is showing) as a PNG in `/screenshots`, the way the Shell's command does. A Toast says so once the file is written, so it is never in the picture.
- **It never reaches an App.** `Key::Screenshot` comes out of the key mapper and is handled before the App manager, so the help panel stays open and a dialog keeps its selection.
- **Not on Settings > Debug Console:** that page shows the token, and a picture of it is a copy of the token in a file. `App::showsSecret()` says so, and the key answers with a Toast instead.
- **No card:** a Toast says so.
- No setting to switch it off: Fn with a letter isn't pressed by accident.
- It is in the "Everywhere" group of the help panel, and so in the website's key tables. `key shot` presses it over the consoles.
**Checked on the device** (2026-10-07, with `key shot`): in the Launcher, a 33,383-byte PNG appears in `/screenshots` and is the Launcher; with the help panel open, the picture is of the panel (which now lists Fn p) and the panel stays open; on Settings > Debug Console, no file is written; back on the Settings list, one is. The test pictures were removed.
**Not checked:** the real Fn+p on the keyboard (the mapper is host-tested); the two Toasts that refuse, which weren't looked at (one of them is on the page that mustn't be photographed); a device with no card.
+4
View File
@@ -39,6 +39,10 @@ class App {
virtual void draw(Canvas& canvas) = 0; virtual void draw(Canvas& canvas) = 0;
// True while the screen shows something a picture of it shouldn't hold: the screenshot key
// (Fn+p, issue #83) then refuses, and says so.
virtual bool showsSecret() const { return false; }
// An App whose screen is costly to draw again (a picture decoded from the card, issue #45) can // An App whose screen is costly to draw again (a picture decoded from the card, issue #45) can
// keep what it drew: while this is true its part of the screen isn't cleared before draw(), // keep what it drew: while this is true its part of the screen isn't cleared before draw(),
// which then draws only what changed. contentLost() says that it was cleared after all, or // which then draws only what changed. contentLost() says that it was cleared after all, or
+7
View File
@@ -27,6 +27,7 @@ inline constexpr KeyHelp kEverywhere[] = {
{"Fn `", "home, the Launcher"}, {"Fn `", "home, the Launcher"},
{"; . , /", "arrows (Fn+ while typing)"}, {"; . , /", "arrows (Fn+ while typing)"},
{"Fn h ?", "these keys (? not typing)"}, {"Fn h ?", "these keys (? not typing)"},
{"Fn p", "a screenshot, on the card"},
}; };
// dialog: A question // dialog: A question
@@ -222,9 +223,15 @@ inline constexpr KeyHelp kStorage[] = {
{"i", "details: size, date, type"}, {"i", "details: size, date, type"},
{"s", "sort: name, date, size"}, {"s", "sort: name, date, size"},
{"m", "Maintenance: clean-up, erase"}, {"m", "Maintenance: clean-up, erase"},
{"w", "share with a browser"},
{"`", "the folder above"}, {"`", "the folder above"},
}; };
// storage-share: Storage, sharing with a browser
inline constexpr KeyHelp kStorageShare[] = {
{"`", "stop sharing"},
};
// storage-details: Storage, an item's details // storage-details: Storage, an item's details
inline constexpr KeyHelp kStorageDetails[] = { inline constexpr KeyHelp kStorageDetails[] = {
{"; .", "scroll"}, {"; .", "scroll"},
+1
View File
@@ -17,6 +17,7 @@ enum class Key : uint8_t {
Tab, Tab,
Delete, Delete,
Help, // Fn+h anywhere, or ? outside Text Entry: the keys of this screen (issue #69) Help, // Fn+h anywhere, or ? outside Text Entry: the keys of this screen (issue #69)
Screenshot, // Fn+p anywhere: the screen as a PNG on the card (issue #83). Never reaches an App
}; };
struct KeyEvent { struct KeyEvent {
+135
View File
@@ -0,0 +1,135 @@
#include "share_rules.h"
#include <cstdio>
namespace roro::files {
namespace {
int hexDigit(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
// Whatever the two strings hold, the time taken says nothing about where they differ.
bool sameText(const std::string& a, const std::string& b) {
unsigned diff = static_cast<unsigned>(a.size() ^ b.size());
for (size_t i = 0; i < a.size() && i < b.size(); i++) diff |= static_cast<unsigned char>(a[i]) ^ static_cast<unsigned char>(b[i]);
return diff == 0;
}
} // namespace
std::string urlDecode(const std::string& text) {
std::string out;
out.reserve(text.size());
for (size_t i = 0; i < text.size(); i++) {
int hi, lo;
if (text[i] == '%' && i + 2 < text.size() + 0 && (hi = hexDigit(text[i + 1])) >= 0 && (lo = hexDigit(text[i + 2])) >= 0) {
out += static_cast<char>(hi * 16 + lo);
i += 2;
} else {
out += text[i];
}
}
return out;
}
bool queryParam(const std::string& query, const std::string& key, std::string& out) {
for (size_t at = 0; at <= query.size();) {
size_t amp = query.find('&', at);
if (amp == std::string::npos) amp = query.size();
size_t eq = query.find('=', at);
if (eq != std::string::npos && eq < amp && query.compare(at, eq - at, key) == 0) {
out = urlDecode(query.substr(eq + 1, amp - eq - 1));
return true;
}
at = amp + 1;
}
return false;
}
std::string cookieValue(const std::string& header, const std::string& name) {
for (size_t at = 0; at < header.size();) {
while (at < header.size() && (header[at] == ' ' || header[at] == ';')) at++;
size_t end = header.find(';', at);
if (end == std::string::npos) end = header.size();
size_t eq = header.find('=', at);
if (eq != std::string::npos && eq < end && header.compare(at, eq - at, name) == 0) return header.substr(eq + 1, end - eq - 1);
at = end;
}
return "";
}
std::string checkSharePath(const std::string& path) {
if (path.empty() || path[0] != '/') return "a path starts with /";
if (path.size() > 255) return "that path is too long";
if (path.size() > 1 && path.back() == '/') return "a path doesn't end with /";
for (size_t at = 1; at < path.size();) {
size_t end = path.find('/', at);
if (end == std::string::npos) end = path.size();
std::string part = path.substr(at, end - at);
if (part.empty() || part == "." || part == "..") return "that isn't a path on the card";
for (char c : part)
if (static_cast<unsigned char>(c) < 0x20 || c == 0x7F || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|')
return "a name can't hold that character";
at = end + 1;
}
return "";
}
std::string jsonString(const std::string& text) {
std::string out = "\"";
for (char c : text) {
unsigned char u = static_cast<unsigned char>(c);
if (c == '"' || c == '\\') {
out += '\\';
out += c;
} else if (u < 0x20) {
char buf[8];
std::snprintf(buf, sizeof buf, "\\u%04x", u);
out += buf;
} else {
out += c;
}
}
return out + "\"";
}
ShareListing::ShareListing(const std::string& path) : out_("{\"path\":" + jsonString(path) + ",\"items\":[") {}
void ShareListing::add(const std::string& name, uint32_t size, bool folder, int64_t modified) {
if (count_++) out_ += ',';
out_ += "{\"n\":" + jsonString(name) + ",\"s\":" + std::to_string(size) + ",\"d\":" + (folder ? "1" : "0") + ",\"t\":" + std::to_string(modified) + "}";
}
std::string ShareListing::json(bool more) { return out_ + "],\"more\":" + (more ? "true" : "false") + "}"; }
void ShareAuth::begin(const uint8_t random[4]) {
uint32_t n = (static_cast<uint32_t>(random[0]) << 24 | random[1] << 16 | random[2] << 8 | random[3]) % 1000000u;
char buf[8];
std::snprintf(buf, sizeof buf, "%06u", static_cast<unsigned>(n));
code_ = buf;
token_.clear();
gate_ = debug::AuthGate();
}
ShareAuth::Result ShareAuth::login(const std::string& code, uint32_t nowMs, const uint8_t random[16], std::string& token) {
if (code_.empty() || gate_.locked(nowMs)) return Result::Locked;
std::string digits;
for (char c : code)
if (c >= '0' && c <= '9') digits += c; // "123 456" is as good
if (!sameText(digits, code_)) return gate_.failed(nowMs) ? Result::Locked : Result::Wrong;
gate_.succeeded();
static const char* const kHex = "0123456789abcdef";
token_.clear();
for (int i = 0; i < 16; i++) {
token_ += kHex[random[i] >> 4];
token_ += kHex[random[i] & 15];
}
token = token_;
return Result::Ok;
}
bool ShareAuth::allowed(const std::string& token) const { return !token_.empty() && sameText(token, token_); }
} // namespace roro::files
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <cstdint>
#include <string>
#include "debug_auth.h"
// The parts of sharing files with a browser (issue #88) that need no network: what a request
// asks for, whether it may, and the answers as JSON. The server itself is src/services/web_share.h.
namespace roro::files {
std::string urlDecode(const std::string& text); // %41 is A; a + stays a +
// The value of `key` in a query string ("path=%2Fnotes&replace=1"), decoded. False if it isn't there.
bool queryParam(const std::string& query, const std::string& key, std::string& out);
// The value of a cookie in a Cookie header ("a=1; s=abc"), or "".
std::string cookieValue(const std::string& header, const std::string& name);
// A path a browser may name: from the card's root, no "..", nothing a file name can't hold.
// "" or why not.
std::string checkSharePath(const std::string& path);
std::string jsonString(const std::string& text); // with its quotes
// A folder's listing as the page wants it: {"path":"/notes","items":[{"n":"a.txt","s":12,"d":0,"t":1791400000}],"more":false}
class ShareListing {
public:
explicit ShareListing(const std::string& path);
void add(const std::string& name, uint32_t size, bool folder, int64_t modified);
std::string json(bool more);
size_t count() const { return count_; }
private:
std::string out_;
size_t count_ = 0;
};
// Who may use the page: whoever typed the code the device's screen shows. The code is new each
// time sharing starts; five wrong ones in a row close the door for a minute (as the Debug
// Console's token does). A browser that got it right is given a token to send back as a cookie.
// Nothing here is encrypted on the way: see the issue.
class ShareAuth {
public:
enum class Result { Ok, Wrong, Locked };
void begin(const uint8_t random[4]); // a new code, and nobody is logged in
const std::string& code() const { return code_; } // six digits
Result login(const std::string& code, uint32_t nowMs, const uint8_t random[16], std::string& token);
bool allowed(const std::string& token) const;
private:
std::string code_, token_;
debug::AuthGate gate_;
};
} // namespace roro::files
+7
View File
@@ -106,6 +106,13 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
return; return;
} }
break; break;
case 'p':
case 'P':
if (keys.fn) { // Fn+p: a screenshot, while typing too
out.push_back(KeyEvent::of(Key::Screenshot));
return;
}
break;
case '?': case '?':
if (!textEntry_ && !keys.fn) { // ? alone, when it wouldn't be typed if (!textEntry_ && !keys.fn) { // ? alone, when it wouldn't be typed
out.push_back(KeyEvent::of(Key::Help)); out.push_back(KeyEvent::of(Key::Help));
+2 -2
View File
@@ -29,7 +29,7 @@ gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it cos
gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum> gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>
crash the last crash: firmware, reason, task, backtrace crash the last crash: firmware, reason, task, backtrace
coredump erase forget the core dump in flash coredump erase forget the core dump in flash
key <name|char> press a key: up down left right select back home del tab space help, or one character; ctrl- alt- shift- before it (key ctrl-down) key <name|char> press a key: up down left right select back home del tab space help shot, or one character; ctrl- alt- shift- before it (key ctrl-down)
wifi status | wifi add <ssid><TAB><password> wifi status | wifi add <ssid><TAB><password>
wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting
wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers
@@ -62,7 +62,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| Command | Effect | | Command | Effect |
|---|---| |---|---|
| `burst` | Publishes 5 Notifications at once | | `burst` | Publishes 5 Notifications at once |
| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` | | `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help\|shot`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing; `shot` is Fn+p: a screenshot). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` |
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) | | `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
+1 -1
View File
@@ -11,7 +11,7 @@ Everything the keyboard can do, a command can do, and everything on the screen c
## Keys ## Keys
``` ```
key up|down|left|right|select|back|home|del|tab|space|help key up|down|left|right|select|back|home|del|tab|space|help|shot
key a # any single character: it is typed key a # any single character: it is typed
``` ```
+43
View File
@@ -298,3 +298,46 @@ Test pictures were copied to a scratch folder and removed afterwards, with the t
**A decoder that worked once.** The library's PNG decoder showed the first picture and refused the next five: "no memory". It wants 44 KB in one piece, and after some use the largest piece is 43 to 47 KB. Writing a decoder that needs 32 KB was less work than it sounds, and unlike the library's it has tests. **A decoder that worked once.** The library's PNG decoder showed the first picture and refused the next five: "no memory". It wants 44 KB in one piece, and after some use the largest piece is 43 to 47 KB. Writing a decoder that needs 32 KB was less work than it sounds, and unlike the library's it has tests.
**Two sentences still said "up to 16 KB"** about editing, in the README and the Storage guide, after issue #47 lifted that. Corrected here. **Two sentences still said "up to 16 KB"** about editing, in the README and the Storage guide, after issue #47 lifted that. Corrected here.
## Sharing the card with a browser (issue #88)
Files reached the card through the Debug Console's `put` and `get`, or by taking the card out. A phone has neither.
### Decisions (2026-10-07; the recommendation was accepted as it stood, without a round of questions)
- **A web page, not FTP, SFTP or WebDAV.** A browser is the only client every phone has. FTP and WebDAV need an app there; SFTP needs a whole SSH server here. WebDAV can come later on the same server, for computers.
- **Off unless asked for:** `w` in the Storage App opens a "Share" screen, and the server runs only while that screen is open.
- **A six-digit code on the screen**, new each time, typed in the page; the address is also a QR code, which carries the code. Five wrong codes close it for a minute (the Debug Console's `AuthGate`). A browser that got it right holds a cookie; starting again puts every browser out.
- **Not encrypted.** A TLS server costs about 40 KB of memory a connection. The screen says so.
- **The Storage App's rules** (`whyReadOnly`): the firmware's own folders, and files in use.
### As built
- **`WebShare`** (`src/services/web_share`): ESP-IDF's HTTP server, which is in the framework already. Seven requests: the page, the code, a listing as JSON, a download, an upload, a new folder, a delete.
- **Every access to the card is handed to the storage task**, 8 KB at a time, from the server's own task: a download and an upload are loops of "one piece from the card, one piece to the network".
- **An upload is the request's body**, as the browser's `PUT` sends it: no form to take apart. It goes to `<name>.part` and is renamed when the last byte has come; anything less is removed. A file that exists is refused unless the page asked, after asking the user.
- **The page** (`web_share_page.h`) is one file of 5.4 KB with its style and script in it, served from flash.
- **`lib/files/src/share_rules.h`** (host-tested, 5 tests): what a request names, which paths a browser may ask for (from the root, no `..`), the JSON, the code and the cookie.
- **Cost:** 57 KB of flash, most of it the server. 13 KB of memory while sharing (108.4 KB free before, 95.4 with the screen open), given back on leaving.
### Checks on the device (2026-10-07 and 08)
A scratch folder was used and removed.
| Check | Result |
|---|---|
| `w` | The QR code, the address and the code; `share: on` on the console |
| The page, and a listing without the code | 200; 401 |
| A wrong code, the right one (typed `825 132`) | 403; in |
| Upload, 2.6 MB | 11 to 17 s (150 to 230 KB/s); downloaded again and compared: the same, byte for byte |
| The same name again; with "replace" | 409; replaced |
| `..` in a path; deleting `/notes`; deleting a folder that isn't empty | 400; 403 "The firmware keeps its files in /notes"; 403 |
| Five wrong codes | The fifth and every one after: 429, the right code too. A browser that was in stays in |
| In Chromium at a phone's width | The scanned address logs in by itself; two files uploaded, one downloaded and compared, a folder made, a file deleted after asking, a replacement after asking, the refusal shown. No sideways scroll |
| Back | The server is gone (connection refused), memory is back |
**Found on the way:** the server answers one request at a time. A second request during a slow download waited until it had ended. It is said in the guide, and not changed.
**On a real phone** (the maintainer's, 2026-10-08): the QR code, scanned with the phone's camera, opens the page and logs in; the page lists the card, in the phone's dark theme.
**Not checked:** Safari. A card pulled during a transfer. Sharing with IRC connected, when memory is shorter. Home, and the screen turning off, while sharing (the code stops the server on leaving the App; only Back was tried).
+15
View File
@@ -52,3 +52,18 @@ The lists first lived in each App's `help()`, as code. They are now **data, in o
Three rows lost their second wording on the way, since a table is constant: GNSS's `Tab` and `r`, and the Scanner's `c`, now say both things they do ("record a Track, or stop it") instead of the one that applies. The guide pages keep their written tables too, where they say more than a key list can; those can still drift, and the generated ones under them are the reference. Three rows lost their second wording on the way, since a table is constant: GNSS's `Tab` and `r`, and the Scanner's `c`, now say both things they do ("record a Track, or stop it") instead of the one that applies. The guide pages keep their written tables too, where they say more than a key list can; those can still drift, and the generated ones under them are the reference.
**Not checked:** the real Fn+h and `?` on the keyboard (the mapper is host-tested; the device was driven with `key help`); the Setup screens, which only a device that was never set up shows, so their new text has not been seen on a screen; and the states that need something to happen first (a dialog, a copy in progress, a Gemini prompt, a packet's details): their lists were read against the key handling, not looked at. **Not checked:** the real Fn+h and `?` on the keyboard (the mapper is host-tested; the device was driven with `key help`); the Setup screens, which only a device that was never set up shows, so their new text has not been seen on a screen; and the states that need something to happen first (a dialog, a copy in progress, a Gemini prompt, a packet's details): their lists were read against the key handling, not looked at.
## The screenshot key (issue #83)
A screenshot could only be taken by typing `screenshot` in the Shell, where "now" is a picture of the Shell.
- **Fn+p, on every screen**, text fields included, saves the screen as it is (a dialog, the help panel or a Toast if one is showing) as a PNG in `/screenshots`, the way the Shell's command does. A Toast says so once the file is written, so it is never in the picture.
- **It never reaches an App.** `Key::Screenshot` comes out of the key mapper and is handled before the App manager, so the help panel stays open and a dialog keeps its selection.
- **Not on Settings > Debug Console:** that page shows the token, and a picture of it is a copy of the token in a file. `App::showsSecret()` says so, and the key answers with a Toast instead.
- **No card:** a Toast says so.
- No setting to switch it off: Fn with a letter isn't pressed by accident.
- It is in the "Everywhere" group of the help panel, and so in the website's key tables. `key shot` presses it over the consoles.
**Checked on the device** (2026-10-07, with `key shot`): in the Launcher, a 33,383-byte PNG appears in `/screenshots` and is the Launcher; with the help panel open, the picture is of the panel (which now lists Fn p) and the panel stays open; on Settings > Debug Console, no file is written; back on the Settings list, one is. The test pictures were removed.
**Not checked:** the real Fn+p on the keyboard (the mapper is host-tested); the two Toasts that refuse, which weren't looked at (one of them is on the page that mustn't be photographed); a device with no card.
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

@@ -0,0 +1,188 @@
+++
title = '''Press w'''
description = '''I asked whether roro9stack should get an FTP, SFTP or WebDAV server, to move files to and from my phone. The answer was none of them: a web page. Less than an hour later I pressed one key on the Cardputer, opened my phone's browser, and my SD card was in it. It works, and I'm still grinning.'''
date = 2026-10-08T00:30:00+02:00
[extra]
topics = '''ESP32-S3 · HTTP · Files'''
read_label = '''Read how the card got into my phone →'''
uid = '''<b>share:</b> on at http://172.16.42.25/'''
dek = "A short one, written straight after it worked, because I'm too pleased to wait. [roro9stack](/devlog/roro9stack/), my firmware for the M5Stack Cardputer, can now hand its SD card to any browser on the same Wi-Fi: no cable, no app, no computer. One key, one code, done."
byline = '''one question asked, the wrong three answers offered, a fourth taken'''
[extra.sign]
label = "Apps installed on the phone to make this work"
note = "A browser was already there."
count = "0"
[[extra.cast]]
name = "The question"
role = "\"FTP, SFTP or WebDAV?\""
text = "Three ways to serve files, all of which want an app on the phone. I'd have picked one and been mildly unhappy with it for months."
[[extra.cast]]
name = "The w key"
role = "in the Storage App"
text = "Starts a web server and shows where it is. Back stops it. That is the entire user interface on the device."
[[extra.cast]]
name = "The code"
role = "six digits, new every time"
text = "On the device's screen and nowhere else. Type it in the page and you're in. Five wrong tries and the door stays shut for a minute."
[[extra.cast]]
name = "The page"
role = "5.4 KB, one file"
text = "A list of what's on the card, an Upload button, a New folder button, and a Delete next to every row. Served from the firmware's own flash."
[[extra.cast]]
name = "The storage task"
role = "the only one allowed to touch the card"
text = "Every byte in either direction goes through it, 8 KB at a time. It was there long before this and didn't need to learn anything new."
+++
## TL;DR
- **Press `w` in the Storage App** (**v0.18.0**), scan the QR code with a phone on the same Wi-Fi, and the SD card is a web page: list, download, upload, new folder, delete.
- **Nothing to install**, on the phone or anywhere else. That was the whole point.
- **It runs only while that screen is open.** A six-digit code, new each time, keeps the rest of the network out.
- **It is not encrypted,** and the screen says so. Fine at home; think first elsewhere.
- About **200 KB a second**, one request at a time, 57 KB of flash, 13 KB of memory while it's on.
- Also since [the last post](/devlog/roro9stack-shell/): the device shows **pictures** (v0.16.0), **Fn+p takes a screenshot** anywhere (v0.17.0), and this site has a **search**.
## It works!
I'll skip the build-up. I pressed `w`. This came up:
{{ figure(src="share.png", alt="The Cardputer's screen at 2x: a large QR code on the left; on the right, In a browser, on this network: 172.16.42.25/, Code 825 132 in large blue digits, Nothing asked yet, Not encrypted, and backtick stops sharing.", width=480, height=270, caption="The whole feature, as the device sees it. The code in this picture stopped being valid the moment I pressed Back.") }}
I pointed my phone's camera at the QR code, and there was my SD card, in the browser. No address to type, no code to type. **It works. That's fucking awesome.**
{{ figure(src="phone.png", alt="A phone's browser in dark mode at the address 172.16.42.25, at 00:15: roro9stack: the SD card, a link SD card, a bright blue Upload files button and a New folder button, then rows captures, gemini, gnss, irc, notes, screenshots, updates and wifi, each with a Delete button.", width=462, height=1001, caption="My phone, at a quarter past midnight. Its browser, my card, nothing else.") }}
{{ figure(src="device-photo.jpg", alt="A photograph of the Cardputer ADV on a wooden table. Its small screen shows the QR code, the address, the code 997 216, and 2.5 MB in, 3.2 MB out. Below the screen, the whole keyboard.", width=900, height=864, landscape=true, caption="And the other end of it, for scale. The whole server is in there, behind a screen smaller than the QR code on most posters.") }}
Files, from my phone, to a computer the size of a biscuit and back. Over Wi-Fi. With nothing installed on either end that wasn't there this morning.
Most of this devlog is about things that took a week of measuring and still bit me. This one I can explain to anybody in a sentence: it's a web page with your files on it.
## The question I asked, and the one I should have
Until tonight a file reached the card in one of two ways: the Debug Console's `put` command, which wants a PC, a Python script and a token, or pulling the card out. My phone can do neither. So I asked the obvious question: FTP, SFTP or WebDAV?
The answer was a table, and the table was unkind to all three:
{% table() %}
| | On the phone | On the device |
|---|---|---|
| FTP | needs an app; passwords in clear | easy |
| SFTP | needs an app | a whole SSH server: hundreds of KB, and a key exchange this chip would feel |
| WebDAV | needs an app, on iOS and Android both | fine, but see the first column |
| **A web page** | **any browser** | a small HTTP server |
{% end %}
I had been choosing a protocol. What I wanted was to move a file with my thumb. Every phone made in the last fifteen years has exactly one file-transfer client that needs no setup, and it's the browser.
## What's in it
**On the device, almost nothing.** `w` starts the server and draws a screen. Back stops it. The server is the one that ships inside ESP-IDF, the framework the firmware is built on, so there was no library to choose. Seven requests: the page, the code, a listing, a download, an upload, a new folder, a delete.
**The QR code carries the code.** Scan it and you're in without typing; type the address by hand and the page asks for the six digits. The code is made fresh from the hardware random generator each time `w` is pressed, and pressing Back throws every browser out.
**An upload is just the request's body.** The browser sends the file as it is with a `PUT`, so there is no form to pick apart on a device with 100 KB of free memory. It lands on the card as `photo.jpg.part`, 8 KB at a time, and is renamed when the last byte has arrived. A transfer that dies halfway leaves nothing behind. If the name is taken, the page asks before replacing it, and the device refuses until it has.
**The Storage App's rules still hold.** The page can't delete the folders the firmware keeps its own files in, and it says why:
{{ figure(src="page.png", alt="The page in a browser at a phone's width: roro9stack: the SD card, a link SD card, buttons Upload files and New folder, then rows a-web, captures, gemini, gnss, irc, notes, screenshots, updates and wifi, each with a Delete button. Below, in orange: The firmware keeps its files in /notes.", width=390, height=630, caption="The page, at a phone's width, just after it was asked to delete `/notes`. It said no, in the device's own words.") }}
**Nobody gets to walk out of the card.** `/a-web/../wifi` is refused before anything looks at the disk, by a function with a test that tries a dozen ways of asking.
## What it isn't
**Encrypted.** A TLS server costs this device about 40 KB of memory per connection, and it has around 100 KB on a good day. So the files and the code cross the Wi-Fi in clear. On my own network I don't mind. On a hotel's, I'd think about it. The device's screen says "Not encrypted." in plain words every time, because a limit you have to read the docs to find is a trap.
**Fast.** 200 KB a second, give or take. A 2.6 MB photo takes eleven to seventeen seconds going up. I tried bigger pieces and smaller ones; the numbers moved around more between two runs of the same setting than between settings, so it's 8 KB and I stopped fiddling.
**Able to do two things at once.** The server answers one request at a time. Start a big download and the page waits until it's done. I found that by asking for a listing in the middle of a download and watching it time out. It's written in the guide and left as it is.
## What went wrong, for about four minutes each
**A file that included itself.** The part that can be tested on a PC lived in `web_share.h`. So did the service, in another folder. The service's header said `#include "web_share.h"`, meaning the other one, and the compiler quite reasonably gave it itself. The testable half is now called `share_rules.h`.
**The scanned address did nothing, sometimes.** If the page was already open and you then went to the same address with the code after the `#`, nothing happened: to a browser that isn't a new page, so the script never ran again. One line, `onhashchange`. Found by a browser test, not by me, which is the right way round.
That's the list. Two.
## What I checked, and what I didn't
Checked, before I went anywhere near my phone:
- Every request and every refusal, from a PC: wrong code, right code, a path with `..` in it, deleting a protected folder, deleting a folder that isn't empty, uploading over a file that exists.
- **2.6 MB up, then down again, compared byte for byte.** The same.
- The page in a real browser at a phone's width: uploads, a download, a new folder, a delete, a replace.
- Five wrong codes: shut for a minute, for the right code too. A browser that was already in stays in.
- Back: the server is gone and the memory comes back.
And then on my actual phone, where it works, which is the sentence this post exists for.
Not checked: Safari. Pulling the card out mid-transfer. Sharing while IRC is connected, when memory is tighter. What happens if the screen turns off while it's sharing.
## Also since last time
The day didn't stop at the [last post](/devlog/roro9stack-shell/):
- **Pictures.** The Storage App opens PNG, JPEG, BMP and GIF files (**v0.16.0**). The interesting part was the PNG decoder: the one in the display library wanted 44 KB in a single block, got it once, and refused the next five pictures. The firmware has its own now, which needs 32.
- **Fn+p** takes a screenshot on any screen (**v0.17.0**), except the one that shows the Debug Console's token, where it politely declines.
- **This site has a [search](/search/).**
- **A WireGuard tunnel** is sitting in a pull request. It works against a test server on my own network and is waiting to meet a real one.
## By the numbers
{% table() %}
| | |
|---|---|
| Keys to press on the device | 1 |
| Apps to install on the phone | 0 |
| Digits in the code | 6 |
| Wrong codes before it shuts for a minute | 5 |
| The page | 5.4 KB |
| Flash | 57 KB |
| Memory while sharing | 13 KB |
| Speed | about 200 KB/s |
| Requests at a time | 1 |
| Bytes that differed after 2.6 MB went up and came back | 0 |
| Host tests | 533 |
| Things that went wrong | 2 |
{% end %}
## Where it stands
{% steps() %}
1. ~~M0 and M1: the skeleton, Wi-Fi, IRC, Wi-Fi Tools.~~ v0.1.0 to v0.2.1, [the first post](/devlog/roro9stack/).
2. ~~Updates and debugging over the air.~~ v0.3.0, [Look, no cables](/devlog/roro9stack-ota/).
3. ~~M2: GNSS.~~ v0.4.0, [Seventeen satellites](/devlog/roro9stack-gnss/).
4. ~~G1: Gemini.~~ v0.5.0, [A browser in the RAM IRC left over](/devlog/roro9stack-gemini/).
5. ~~M3: the LoRa radio, listening.~~ v0.6.0, [The loudest thing it hears is itself](/devlog/roro9stack-lora/).
6. ~~S1: the card, fixed addresses, the System App.~~ v0.6.1 to v0.8.1, [One byte too early](/devlog/roro9stack-s1/).
7. ~~F1 and the start of R1: files, notes, signed releases, updates from Gitea.~~ v0.9.0 to v0.11.0, [836 bytes](/devlog/roro9stack-f1-r1/).
8. ~~W1: the website, and one firmware with the Debug Console in it.~~ v0.12.0, [It was off](/devlog/roro9stack-console/).
9. ~~A help key, the Shell, notes of any size.~~ v0.13.0 to v0.15.0, [It said "No"](/devlog/roro9stack-shell/).
10. ~~Pictures, and a screenshot key.~~ v0.16.0 and v0.17.0.
11. ~~The card in a phone's browser.~~ v0.18.0, this post.
12. Next: the WireGuard tunnel, once it has talked to a real server. And M4, the mesh, which still wants a second node.
{% end %}
{% signoff() %}
I asked which of three servers to build and got told to build none of them. Then I pressed a key, picked up my phone, and my files were on it. Most of what this firmware does took days of careful measuring to get right. This took less than an evening, and it works, and I'm going to enjoy that at least until the next thing breaks.
{% end %}
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+4
View File
@@ -59,6 +59,10 @@ Yes: it is [open source](https://git.twis.la/twisla/roro9stack) (GPL-3.0). The d
A secure connection takes about 52 KB of the 107 KB the device has, and IRC's takes about 40 KB. Both together do not always fit. See [When a connection says "not enough memory"](/howto/not-enough-memory/). A secure connection takes about 52 KB of the 107 KB the device has, and IRC's takes about 40 KB. Both together do not always fit. See [When a connection says "not enough memory"](/howto/not-enough-memory/).
## How do I copy files to and from my phone?
In the Storage App, press <kbd>w</kbd>: the device serves a small web page to any browser on the same Wi-Fi. Scan the QR code it shows, type the code, and upload or download. Nothing to install. See [From a phone](/guide/storage/#from-a-phone).
## Do I need an SD card? ## Do I need an SD card?
For the radio, GNSS position, Wi-Fi tools, IRC chat and Gemini browsing, no. For anything that is *kept*, yes: notes, IRC logs, Wi-Fi scan logs, GNSS Tracks, LoRa captures, saved Gemini pages and update files. See [Find your files on the SD card](/howto/sd-files/). For the radio, GNSS position, Wi-Fi tools, IRC chat and Gemini browsing, no. For anything that is *kept*, yes: notes, IRC logs, Wi-Fi scan logs, GNSS Tracks, LoRa captures, saved Gemini pages and update files. See [Find your files on the SD card](/howto/sd-files/).
+1
View File
@@ -26,6 +26,7 @@ The Cardputer's keyboard has no arrow keys and no Escape, so the firmware gives
| <kbd>Fn</kbd> + <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> | The arrows: up, down, left, right | | <kbd>Fn</kbd> + <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> | The arrows: up, down, left, right |
| <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> alone | The same arrows, as long as you are **not** typing text | | <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> alone | The same arrows, as long as you are **not** typing text |
| <kbd>Fn</kbd> + <kbd>h</kbd>, or <kbd>?</kbd> when not typing | **Help:** the keys of the screen you are on | | <kbd>Fn</kbd> + <kbd>h</kbd>, or <kbd>?</kbd> when not typing | **Help:** the keys of the screen you are on |
| <kbd>Fn</kbd> + <kbd>p</kbd> | **A screenshot:** the screen as it is, saved as a picture in `/screenshots` on the SD card |
| <kbd>Tab</kbd> | Switches view in an App that has more than one | | <kbd>Tab</kbd> | Switches view in an App that has more than one |
| <kbd>Del</kbd> | Deletes backwards when you type | | <kbd>Del</kbd> | Deletes backwards when you type |
| <kbd>opt</kbd> then an accent, then a letter | Types an accented letter: <kbd>opt</kbd> <kbd>'</kbd> <kbd>e</kbd> gives é | | <kbd>opt</kbd> then an accent, then a letter | Types an accented letter: <kbd>opt</kbd> <kbd>'</kbd> <kbd>e</kbd> gives é |
+1 -1
View File
@@ -77,7 +77,7 @@ screenshot the screen, now
screenshot 5 the screen in 5 seconds: time to go to another App screenshot 5 the screen in 5 seconds: time to go to another App
``` ```
The picture is saved as a PNG in `/screenshots` on the SD card, named by date and time, and a Toast says so once it is written (so the Toast is never in the picture). From the Shell, "now" is always a picture of the Shell: use the pause to get to the screen you want. The [Storage App](/guide/storage/) shows the files; to look at them, take the card to a computer. The picture is saved as a PNG in `/screenshots` on the SD card, named by date and time, and a Toast says so once it is written (so the Toast is never in the picture). From the Shell, "now" is always a picture of the Shell: use the pause to get to the screen you want, or, simpler, press <kbd>Fn</kbd> + <kbd>p</kbd> on that screen: it takes the same picture from anywhere. The [Storage App](/guide/storage/) lists the files and [shows them](/guide/storage/#pictures).
## What it costs ## What it costs
+17 -1
View File
@@ -51,6 +51,22 @@ The App says why when it refuses.
- **What can't be shown** opens as hex, with the reason: a progressive JPEG, an interlaced PNG, a BMP that is compressed or has 16 bits a pixel. - **What can't be shown** opens as hex, with the reason: a progressive JPEG, an interlaced PNG, a BMP that is compressed or has 16 bits a pixel.
- **A PNG needs 32 KB of memory in one piece** while it is decoded, and a few more (a JPEG needs 4 KB, a GIF 17 KB). With IRC connected there may not be that much: the viewer says so. The firmware's own screenshots need none. - **A PNG needs 32 KB of memory in one piece** while it is decoded, and a few more (a JPEG needs 4 KB, a GIF 17 KB). With IRC connected there may not be that much: the viewer says so. The firmware's own screenshots need none.
## From a phone
Press <kbd>w</kbd> in the Storage App to **share the card with a browser** on the same network. The screen shows an address, as a QR code and in letters, and a six-digit code.
1. On the phone (or any computer on the same Wi-Fi), scan the QR code, or type the address and then the code.
2. The page lists the card. Tap a folder to open it and a file to download it. **Upload files** sends files from the phone into the folder you are in; **New folder** and **Delete** do what they say.
3. Press Back on the device to stop. Sharing also stops when you leave the Storage App.
What to know:
- **It runs only while that screen is open**, and the code is new each time. Five wrong codes close the door for a minute.
- **It is not encrypted.** On your own network that is the usual trade; on a network you don't trust, someone listening could read the files and the code. Inside a VPN tunnel it is protected.
- **One thing at a time:** while a big file is going up or down, the page waits. About 200 KB a second.
- The same rules as on the device: the folders the firmware keeps for itself can't be deleted, and a folder has to be empty to be deleted from the page.
- An upload is written under a temporary name and renamed when it is whole, so a transfer that is cut leaves nothing behind.
## Maintenance ## Maintenance
At the top of the card, the last row, **Maintenance** (or <kbd>m</kbd>), shows the card's usage and holds **Storage clean-up** and **Erase SD card**. They delete for good, so they sit behind a warning. Clean-up deletes old logs and captures by category and age, showing the space it would free first. Notes and Saved Pages are never offered. At the top of the card, the last row, **Maintenance** (or <kbd>m</kbd>), shows the card's usage and holds **Storage clean-up** and **Erase SD card**. They delete for good, so they sit behind a warning. Clean-up deletes old logs and captures by category and age, showing the space it would free first. Notes and Saved Pages are never offered.
@@ -61,4 +77,4 @@ The firmware warns once per start when the card passes **80%** full; past **90%*
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones. What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["storage", "storage-details", "storage-name", "storage-busy", "maintenance", "viewer-text", "viewer-hex", "viewer-pcap", "viewer-packet", "viewer-gpx", "viewer-ota", "viewer-image"]) }} {{ keys(scopes=["storage", "storage-details", "storage-name", "storage-busy", "maintenance", "viewer-text", "viewer-hex", "viewer-pcap", "viewer-packet", "viewer-gpx", "viewer-ota", "viewer-image", "storage-share"]) }}
+9
View File
@@ -9,6 +9,7 @@ rows = [
["Fn `", "home, the Launcher"], ["Fn `", "home, the Launcher"],
["; . , /", "arrows (Fn+ while typing)"], ["; . , /", "arrows (Fn+ while typing)"],
["Fn h ?", "these keys (? not typing)"], ["Fn h ?", "these keys (? not typing)"],
["Fn p", "a screenshot, on the card"],
] ]
[[scope]] [[scope]]
@@ -248,9 +249,17 @@ rows = [
["i", "details: size, date, type"], ["i", "details: size, date, type"],
["s", "sort: name, date, size"], ["s", "sort: name, date, size"],
["m", "Maintenance: clean-up, erase"], ["m", "Maintenance: clean-up, erase"],
["w", "share with a browser"],
["`", "the folder above"], ["`", "the folder above"],
] ]
[[scope]]
id = "storage-share"
title = "Storage, sharing with a browser"
rows = [
["`", "stop sharing"],
]
[[scope]] [[scope]]
id = "storage-details" id = "storage-details"
title = "Storage, an item's details" title = "Storage, an item's details"
+1
View File
@@ -52,6 +52,7 @@ class SettingsApp : public App {
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override; void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override; const char* helpTitle() const override;
bool showsSecret() const override { return page_ == Page::Debug; } // the Debug Console's token
private: private:
enum class Page { Menu, Text, Choice, About, Wifi, Firmware, Debug }; enum class Page { Menu, Text, Choice, About, Wifi, Firmware, Debug };
+1 -1
View File
@@ -26,7 +26,7 @@ void ShellApp::onEnter() {
scroll_ = 0; scroll_ = 0;
confirm_.reset(); confirm_.reset();
// What Tab completes besides the firmware's own commands, written as `help` writes them. // What Tab completes besides the firmware's own commands, written as `help` writes them.
ownHelp_ = "help | clear | quit | exit\nkey up|down|left|right|select|back|home|del|tab|space|help\n"; ownHelp_ = "help | clear | quit | exit\nkey up|down|left|right|select|back|home|del|tab|space|help|shot\n";
log_.clear(); log_.clear();
log_.add(open_ ? "The console's commands. `help` lists them." : "No memory for the Shell: leave an App, or stop IRC."); log_.add(open_ ? "The console's commands. `help` lists them." : "No memory for the Shell: leave an App, or stop IRC.");
} }
+56
View File
@@ -48,6 +48,7 @@ void StorageApp::onEnter() {
} }
void StorageApp::onExit() { void StorageApp::onExit() {
share_.stop(); // sharing lasts as long as its screen
if (wait_ != Wait::None && wait_ != Wait::Work) { if (wait_ != Wait::None && wait_ != Wait::Work) {
ops_.cancel(); ops_.cancel();
wait_ = Wait::None; wait_ = Wait::None;
@@ -97,6 +98,13 @@ bool StorageApp::work(const std::string& why, const std::string& selectAfter) {
void StorageApp::update(uint32_t nowMs) { void StorageApp::update(uint32_t nowMs) {
bool present = storage_.state().present; bool present = storage_.state().present;
if (view_ == View::Share) {
if (!present || !share_.running()) {
share_.stop();
view_ = View::Browse;
}
if (nowMs - lastDrawMs_ >= 500) requestRedraw(); // what the browser is doing
}
if (present != cardPresent_) { // taken out, put in, or erased: back to the top if (present != cardPresent_) { // taken out, put in, or erased: back to the top
cardPresent_ = present; cardPresent_ = present;
cwd_ = want_ = "/"; cwd_ = want_ = "/";
@@ -281,6 +289,7 @@ void StorageApp::help(std::vector<KeyHelp>& out) const {
case View::Editor: return noteEditor_.help(out); case View::Editor: return noteEditor_.help(out);
case View::Viewer: return viewer_.help(out); case View::Viewer: return viewer_.help(out);
case View::Details: return keys::add(out, keys::kStorageDetails); case View::Details: return keys::add(out, keys::kStorageDetails);
case View::Share: return keys::add(out, keys::kStorageShare);
default: break; default: break;
} }
if (dialog_) return keys::add(out, keys::kDialog); if (dialog_) return keys::add(out, keys::kDialog);
@@ -295,6 +304,7 @@ const char* StorageApp::helpTitle() const {
case View::Editor: return "Storage: the editor"; case View::Editor: return "Storage: the editor";
case View::Viewer: return "Storage: a file"; case View::Viewer: return "Storage: a file";
case View::Details: return "Storage: details"; case View::Details: return "Storage: details";
case View::Share: return "Storage: sharing";
case View::Name: return "Storage: a name"; case View::Name: return "Storage: a name";
default: return nullptr; default: return nullptr;
} }
@@ -303,6 +313,14 @@ const char* StorageApp::helpTitle() const {
bool StorageApp::onKey(const KeyEvent& e) { bool StorageApp::onKey(const KeyEvent& e) {
requestRedraw(); requestRedraw();
if (view_ == View::NoMemory) return false; if (view_ == View::NoMemory) return false;
if (view_ == View::Share) {
if (e.key == Key::Back) {
share_.stop();
view_ = View::Browse;
open(cwd_); // what was uploaded or deleted meanwhile
}
return true;
}
if (view_ == View::Maintenance) { if (view_ == View::Maintenance) {
if (!maintenance_.onKey(e)) { if (!maintenance_.onKey(e)) {
view_ = View::Browse; view_ = View::Browse;
@@ -413,6 +431,12 @@ bool StorageApp::onBrowseKey(const KeyEvent& e) {
} }
uint32_t ch = e.key == Key::Char ? (e.ch >= 'A' && e.ch <= 'Z' ? e.ch + 32 : e.ch) : 0; uint32_t ch = e.key == Key::Char ? (e.ch >= 'A' && e.ch <= 'Z' ? e.ch + 32 : e.ch) : 0;
if (ch == 'm') askMaintenance(); if (ch == 'm') askMaintenance();
if (ch == 'w') {
std::string why = share_.start();
if (why.empty()) view_ = View::Share;
else say(why);
return true;
}
if (!cardPresent_ || !loaded_) return true; if (!cardPresent_ || !loaded_) return true;
Item item = selected(); Item item = selected();
@@ -507,6 +531,7 @@ void StorageApp::draw(Canvas& c) {
c.drawString("in Gemini) and open Storage again.", 4, area.y + 22 + theme::kLineHeight); c.drawString("in Gemini) and open Storage again.", 4, area.y + 22 + theme::kLineHeight);
return; return;
case View::Maintenance: maintenance_.draw(c); return; case View::Maintenance: maintenance_.draw(c); return;
case View::Share: drawShare(c); return;
case View::Viewer: viewer_.draw(c); return; case View::Viewer: viewer_.draw(c); return;
case View::Editor: noteEditor_.draw(c); return; case View::Editor: noteEditor_.draw(c); return;
case View::Details: { case View::Details: {
@@ -539,6 +564,37 @@ void StorageApp::draw(Canvas& c) {
} }
} }
// The address as a QR code and in letters, the code to type, and what the browser has done.
void StorageApp::drawShare(Canvas& c) {
const auto& area = theme::kContent;
std::string url = share_.url(), code = share_.code();
const int qr = 104;
c.fillRect(2, area.y + 3, qr + 6, qr + 6, 0xFFFF); // a QR code wants a quiet border
c.qrcode((url + "#" + code).c_str(), 5, area.y + 6, qr);
int x = qr + 14, y = area.y + 4;
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString("In a browser, on", x, y);
c.drawString("this network:", x, y + 10);
c.setTextColor(theme::kText);
c.drawString(url.size() > 7 ? url.substr(7).c_str() : url.c_str(), x, y + 24); // without http://
c.setTextColor(theme::kMuted);
c.drawString("Code", x, y + 40);
c.setFont(&fonts::bold);
c.setTextColor(theme::kAccent);
std::string grouped = code.size() == 6 ? code.substr(0, 3) + " " + code.substr(3) : code;
c.drawString(grouped.c_str(), x, y + 50);
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
std::string moved = formatBytes(share_.bytesIn()) + " in, " + formatBytes(share_.bytesOut()) + " out";
c.drawString(share_.requests() ? moved.c_str() : "Nothing asked yet", x, y + 72);
c.drawString("Not encrypted.", x, y + 84);
c.drawString("` stops sharing", x, y + 96);
c.setTextColor(theme::kText);
std::string last = share_.last();
if (!last.empty()) c.drawString(last.c_str(), 4, area.y + area.h - 9);
}
void StorageApp::drawBrowse(Canvas& c) { void StorageApp::drawBrowse(Canvas& c) {
const auto& area = theme::kContent; const auto& area = theme::kContent;
StorageState card = storage_.state(); StorageState card = storage_.state();
+6 -3
View File
@@ -16,6 +16,7 @@
#include "services/clock_service.h" #include "services/clock_service.h"
#include "services/file_ops.h" #include "services/file_ops.h"
#include "services/storage_service.h" #include "services/storage_service.h"
#include "services/web_share.h"
#include "ui/theme.h" #include "ui/theme.h"
namespace roro { namespace roro {
@@ -26,8 +27,8 @@ namespace roro {
// usage, Storage Clean-up and erasing the card, behind a warning (Q128). // usage, Storage Clean-up and erasing the card, behind a warning (Q128).
class StorageApp : public App { class StorageApp : public App {
public: public:
StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, PowerService& power, EventBus& bus) StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, PowerService& power, EventBus& bus, WebShare& share)
: ops_(ops), storage_(storage), bus_(bus), maintenance_(storage, clock, bus), viewer_(storage, update), noteEditor_(storage, clock, power) {} : ops_(ops), storage_(storage), bus_(bus), share_(share), maintenance_(storage, clock, bus), viewer_(storage, update), noteEditor_(storage, clock, power) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
@@ -40,7 +41,7 @@ class StorageApp : public App {
const char* helpTitle() const override; const char* helpTitle() const override;
private: private:
enum class View { Browse, Details, Name, Viewer, Editor, Maintenance, NoMemory }; enum class View { Browse, Details, Name, Viewer, Editor, Maintenance, NoMemory, Share };
enum class Ask { None, Delete, Replace, Maintenance }; enum class Ask { None, Delete, Replace, Maintenance };
enum class Wait { None, List, CountToDelete, CountForDetails, Work }; // what the running operation is for enum class Wait { None, List, CountToDelete, CountForDetails, Work }; // what the running operation is for
static constexpr int kRows = 8; static constexpr int kRows = 8;
@@ -66,10 +67,12 @@ class StorageApp : public App {
void say(const std::string& text); void say(const std::string& text);
void drawBrowse(Canvas& c); void drawBrowse(Canvas& c);
void drawProgress(Canvas& c); void drawProgress(Canvas& c);
void drawShare(Canvas& c); // `w`: the card in a browser on the same network (issue #88)
FileOps& ops_; FileOps& ops_;
StorageService& storage_; StorageService& storage_;
EventBus& bus_; EventBus& bus_;
WebShare& share_;
MaintenancePage maintenance_; MaintenancePage maintenance_;
FileViewer viewer_; FileViewer viewer_;
NoteEditor noteEditor_; // `e` in the text viewer (Q146) NoteEditor noteEditor_; // `e` in the text viewer (Q146)
+26 -5
View File
@@ -12,6 +12,7 @@
#include "apps/demo_app.h" #include "apps/demo_app.h"
#include "apps/note_editor.h" #include "apps/note_editor.h"
#include "apps/shell_app.h" #include "apps/shell_app.h"
#include "services/web_share.h"
#include "apps/gemini_app.h" #include "apps/gemini_app.h"
#include "apps/gnss_app.h" #include "apps/gnss_app.h"
#include "apps/irc_app.h" #include "apps/irc_app.h"
@@ -216,7 +217,7 @@ void setup() {
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)}); apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)});
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)}); apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)});
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)}); apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus)}); apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *new WebShare(*storageService, *fileOps, *wifi))});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)}); apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)}); apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)});
// Leaving the foreground App makes it save: a note being typed, when the device is powered off. // Leaving the foreground App makes it save: a note being typed, when the device is powered off.
@@ -656,7 +657,7 @@ static const char* const kHelp =
"gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n" "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" "crash the last crash: firmware, reason, task, backtrace\n"
"coredump erase forget the core dump in flash\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 space help, or one character; ctrl- alt- shift- before it (key ctrl-down)\n" "key <name|char> press a key: up down left right select back home del tab space help shot, or one character; ctrl- alt- shift- before it (key ctrl-down)\n"
"wifi status | wifi add <ssid><TAB><password>\n" "wifi status | wifi add <ssid><TAB><password>\n"
"wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting\n" "wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting\n"
"wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers\n" "wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers\n"
@@ -725,6 +726,26 @@ static void saveScreenshot() {
}); });
} }
// Fn+p (issue #83): the screen as it is, dialog, help panel or Toast included. Not the page that
// shows the Debug Console's token: a picture of it is a copy of the token in a file.
static void screenshotKey() {
const char* why = nullptr;
if (apps->foreground().showsSecret()) why = "No screenshot here: it shows the token";
else if (!storageService || !storageService->state().present) why = "No SD card for the screenshot";
if (why) {
bus.publish(Event::withText(EventType::Notification, why, static_cast<int32_t>(NotificationLevel::Warning)));
return;
}
shotFrom = Console::Origin::System;
saveScreenshot();
}
// Every key goes through here, from the keyboard or from a console's `key`.
static void handleKey(const KeyEvent& e) {
if (e.key == Key::Screenshot) return screenshotKey();
apps->handleKey(e);
}
static void screenshotStep() { static void screenshotStep() {
if (shotPending && static_cast<int32_t>(millis() - shotDueMs) >= 0) { if (shotPending && static_cast<int32_t>(millis() - shotDueMs) >= 0) {
shotPending = false; shotPending = false;
@@ -1039,14 +1060,14 @@ static void runCommand(String line, bool fromSerial = false) {
} }
Key key = k == "up" ? Key::Up : k == "down" ? Key::Down : k == "left" ? Key::Left 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 == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home
: k == "del" ? Key::Delete : k == "tab" ? Key::Tab : k == "help" ? Key::Help : Key::Select; : k == "del" ? Key::Delete : k == "tab" ? Key::Tab : k == "help" ? Key::Help : k == "shot" ? Key::Screenshot : Key::Select;
KeyEvent ev = k == "space" ? KeyEvent::character(' ') KeyEvent ev = k == "space" ? KeyEvent::character(' ')
: k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0]) : k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0])
: KeyEvent::of(key); : KeyEvent::of(key);
ev.ctrl = ctrl; ev.ctrl = ctrl;
ev.alt = alt; ev.alt = alt;
ev.shift = shift; ev.shift = shift;
if (!power->onKey(millis())) apps->handleKey(ev); if (!power->onKey(millis())) handleKey(ev);
} }
if (line.startsWith("wifi add ")) { // wifi add <ssid>\t<password>: credentials never touch the repo if (line.startsWith("wifi add ")) { // wifi add <ssid>\t<password>: credentials never touch the repo
int tab = line.indexOf('\t'); int tab = line.indexOf('\t');
@@ -1385,7 +1406,7 @@ static void loopPass() {
keyMapper.setTextEntry(apps->foreground().textEntryActive()); keyMapper.setTextEntry(apps->foreground().textEntryActive());
auto events = keyMapper.update(readKeys()); // always, so held keys are tracked auto events = keyMapper.update(readKeys()); // always, so held keys are tracked
if (!swallow) if (!swallow)
for (auto& e : events) apps->handleKey(e); for (auto& e : events) handleKey(e);
} }
// Issue #20: the GNSS receiver costs the LoRa radio 8 dB while it runs. If the user chose so, // Issue #20: the GNSS receiver costs the LoRa radio 8 dB while it runs. If the user chose so,
+335
View File
@@ -0,0 +1,335 @@
#include "services/web_share.h"
#include <Arduino.h>
#include <SD.h>
#include <esp_http_server.h>
#include <esp_random.h>
#include <memory>
#include "file_list.h"
#include "file_names.h"
#include "platform/console.h"
#include "services/web_share_page.h"
namespace roro {
namespace {
constexpr size_t kPiece = 8192; // read from or written to the card at once
constexpr size_t kMaxListed = 300;
WebShare& shareOf(httpd_req_t* req) { return *static_cast<WebShare*>(req->user_ctx); }
esp_err_t reply(httpd_req_t* req, const char* status, const std::string& text) {
httpd_resp_set_status(req, status);
httpd_resp_set_type(req, "text/plain; charset=utf-8");
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
return httpd_resp_send(req, text.c_str(), static_cast<ssize_t>(text.size()));
}
std::string queryOf(httpd_req_t* req) {
size_t len = httpd_req_get_url_query_len(req);
if (!len || len > 600) return "";
std::string q(len + 1, '\0');
if (httpd_req_get_url_query_str(req, &q[0], len + 1) != ESP_OK) return "";
q.resize(len);
return q;
}
// The request's path, checked; or an answer already sent and false.
bool pathOf(httpd_req_t* req, std::string& path) {
std::string query = queryOf(req);
if (!files::queryParam(query, "path", path)) return reply(req, "400 Bad Request", "No path"), false;
std::string why = files::checkSharePath(path);
if (!why.empty()) return reply(req, "400 Bad Request", why), false;
return true;
}
// Has this browser typed the code? If not: 401, and false.
bool allowed(httpd_req_t* req) {
char cookie[160] = "";
httpd_req_get_hdr_value_str(req, "Cookie", cookie, sizeof cookie);
if (shareOf(req).auth().allowed(files::cookieValue(cookie, "s"))) return true;
reply(req, "401 Unauthorized", "The code, please");
return false;
}
esp_err_t noCard(httpd_req_t* req) { return reply(req, "503 Service Unavailable", "No SD card"); }
esp_err_t page(httpd_req_t* req) {
httpd_resp_set_type(req, "text/html; charset=utf-8");
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
return httpd_resp_send(req, kWebSharePage, sizeof kWebSharePage - 1);
}
esp_err_t login(httpd_req_t* req) {
char body[64] = "";
int n = httpd_req_recv(req, body, std::min<size_t>(req->content_len, sizeof body - 1));
std::string code;
files::queryParam(n > 0 ? std::string(body, static_cast<size_t>(n)) : "", "code", code);
uint8_t random[16];
esp_fill_random(random, sizeof random);
std::string token;
switch (shareOf(req).auth().login(code, millis(), random, token)) {
case files::ShareAuth::Result::Ok: {
std::string cookie = "s=" + token + "; HttpOnly; SameSite=Strict; Path=/";
httpd_resp_set_hdr(req, "Set-Cookie", cookie.c_str());
shareOf(req).note("a browser opened", "");
return reply(req, "200 OK", "ok");
}
case files::ShareAuth::Result::Locked: return reply(req, "429 Too Many Requests", "Too many wrong codes: wait a minute");
default: return reply(req, "403 Forbidden", "That isn't the code on the screen");
}
}
esp_err_t list(httpd_req_t* req) {
std::string path;
if (!allowed(req) || !pathOf(req, path)) return ESP_OK;
WebShare& share = shareOf(req);
std::string json, why;
bool ran = share.storage().runAndWait([&]() {
File dir = SD.open(path.c_str());
if (!dir || !dir.isDirectory()) {
why = "No such folder";
return;
}
files::ShareListing listing(path);
bool more = false;
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
if (listing.count() >= kMaxListed) {
more = true;
f.close();
break;
}
listing.add(f.name(), static_cast<uint32_t>(f.size()), f.isDirectory(), static_cast<int64_t>(f.getLastWrite()));
f.close();
}
dir.close();
json = listing.json(more);
});
if (!ran) return noCard(req);
if (!why.empty()) return reply(req, "404 Not Found", why);
share.note("listed", path);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
return httpd_resp_send(req, json.c_str(), static_cast<ssize_t>(json.size()));
}
esp_err_t download(httpd_req_t* req) {
std::string path;
if (!allowed(req) || !pathOf(req, path)) return ESP_OK;
WebShare& share = shareOf(req);
auto file = std::make_shared<File>();
std::unique_ptr<char[]> piece(new (std::nothrow) char[kPiece]);
if (!piece) return reply(req, "503 Service Unavailable", "Not enough memory");
bool found = false;
if (!share.storage().runAndWait([&]() {
*file = SD.open(path.c_str(), FILE_READ);
found = *file && !file->isDirectory();
if (*file && !found) file->close();
}))
return noCard(req);
if (!found) return reply(req, "404 Not Found", "No such file");
share.note("sent", path);
std::string disposition = "attachment; filename=" + files::jsonString(files::baseName(path));
httpd_resp_set_type(req, "application/octet-stream");
httpd_resp_set_hdr(req, "Content-Disposition", disposition.c_str());
esp_err_t err = ESP_OK;
for (;;) {
int n = 0;
if (!share.storage().runAndWait([&]() { n = file->read(reinterpret_cast<uint8_t*>(piece.get()), kPiece); })) {
err = ESP_FAIL;
break;
}
if (n <= 0) break;
err = httpd_resp_send_chunk(req, piece.get(), n);
if (err != ESP_OK) break; // the browser went away
share.counted(0, static_cast<uint32_t>(n));
}
share.storage().runJob([file]() { file->close(); });
if (err == ESP_OK) return httpd_resp_send_chunk(req, nullptr, 0);
return ESP_FAIL; // closes the connection: the browser sees a download cut short, not a whole file
}
// The file arrives as the request's body and goes to the card a piece at a time, under a
// temporary name: the real one only ever holds a whole file.
esp_err_t upload(httpd_req_t* req) {
std::string path;
if (!allowed(req) || !pathOf(req, path)) return ESP_OK;
WebShare& share = shareOf(req);
std::string replace;
bool mayReplace = files::queryParam(queryOf(req), "replace", replace) && replace == "1";
std::string name = files::baseName(path), why = name.empty() ? "No name" : files::checkName(name);
if (!why.empty()) return reply(req, "400 Bad Request", why);
std::string part = path + ".part";
auto file = std::make_shared<File>();
std::unique_ptr<char[]> piece(new (std::nothrow) char[kPiece]);
if (!piece) return reply(req, "503 Service Unavailable", "Not enough memory");
const char* status = nullptr;
size_t total = req->content_len;
bool ran = share.storage().runAndWait([&]() {
File parent = SD.open(files::parentOf(path).c_str());
bool folder = parent && parent.isDirectory();
if (parent) parent.close();
if (!folder) {
status = "404 Not Found";
why = "No such folder";
return;
}
if (SD.exists(path.c_str())) {
File there = SD.open(path.c_str());
bool isFolder = there && there.isDirectory();
if (there) there.close();
std::string readOnly = isFolder ? "A folder has that name" : mayReplace ? share.ops().whyReadOnly(path, false) : "";
if (!readOnly.empty()) {
status = "403 Forbidden";
why = readOnly;
return;
}
if (!mayReplace) {
status = "409 Conflict";
why = "It is there already";
return;
}
}
StorageState state = share.storage().state();
if (state.totalBytes - state.usedBytes < static_cast<uint64_t>(total) + 65536) {
status = "507 Insufficient Storage";
why = "Not enough room on the card";
return;
}
*file = SD.open(part.c_str(), FILE_WRITE);
if (!*file) {
status = "500 Internal Server Error";
why = "The card refused to make the file";
}
});
if (!ran) return noCard(req);
if (status) return reply(req, status, why);
share.note("receiving", path);
size_t got = 0;
int idle = 0;
bool wrote = true;
while (got < total && wrote) {
int n = httpd_req_recv(req, piece.get(), std::min(kPiece, total - got));
if (n == HTTPD_SOCK_ERR_TIMEOUT && ++idle < 3) continue;
if (n <= 0) break;
idle = 0;
if (!share.storage().runAndWait([&]() { wrote = file->write(reinterpret_cast<const uint8_t*>(piece.get()), static_cast<size_t>(n)) == static_cast<size_t>(n); })) wrote = false;
got += static_cast<size_t>(n);
share.counted(static_cast<uint32_t>(n), 0);
}
bool whole = wrote && got == total, placed = false;
share.storage().runAndWait([&]() {
file->close();
if (whole) {
if (SD.exists(path.c_str())) SD.remove(path.c_str());
placed = SD.rename(part.c_str(), path.c_str());
}
if (!placed) SD.remove(part.c_str());
});
if (placed) {
share.note("received", path);
return reply(req, "200 OK", "ok");
}
if (got < total) return ESP_FAIL; // the browser went away, or the network did
return reply(req, "500 Internal Server Error", "The card refused a write");
}
esp_err_t remove(httpd_req_t* req) {
std::string path;
if (!allowed(req) || !pathOf(req, path)) return ESP_OK;
WebShare& share = shareOf(req);
if (path == "/") return reply(req, "403 Forbidden", "Not the card itself");
std::string why;
const char* status = "403 Forbidden";
bool ran = share.storage().runAndWait([&]() {
File f = SD.open(path.c_str());
if (!f) {
status = "404 Not Found";
why = "It isn't there";
return;
}
bool folder = f.isDirectory();
f.close();
why = share.ops().whyReadOnly(path, folder);
if (!why.empty()) return;
if (folder ? !SD.rmdir(path.c_str()) : !SD.remove(path.c_str())) why = folder ? "That folder isn't empty: delete what is in it first" : "The card refused";
});
if (!ran) return noCard(req);
if (!why.empty()) return reply(req, status, why);
share.note("deleted", path);
return reply(req, "200 OK", "ok");
}
esp_err_t makeFolder(httpd_req_t* req) {
std::string path;
if (!allowed(req) || !pathOf(req, path)) return ESP_OK;
WebShare& share = shareOf(req);
std::string why = files::checkName(files::baseName(path));
if (!why.empty()) return reply(req, "400 Bad Request", why);
bool ran = share.storage().runAndWait([&]() {
if (SD.exists(path.c_str())) why = "It is there already";
else if (!SD.mkdir(path.c_str())) why = "The card refused";
});
if (!ran) return noCard(req);
if (!why.empty()) return reply(req, "409 Conflict", why);
share.note("made", path);
return reply(req, "200 OK", "ok");
}
} // namespace
void WebShare::note(const char* what, const std::string& path) {
requests_++;
std::string text = path.empty() ? what : std::string(what) + " " + files::fitName(files::baseName(path).empty() ? "/" : files::baseName(path), 26);
strlcpy(last_, text.c_str(), sizeof last_);
}
std::string WebShare::last() const { return last_; }
std::string WebShare::url() const {
std::string ip = wifi_.ip();
return ip.empty() ? "" : "http://" + ip + "/";
}
std::string WebShare::start() {
if (server_) return "";
if (wifi_.ip().empty()) return "Wi-Fi isn't connected";
if (!storage_.state().present) return "No SD card";
uint8_t random[4];
esp_fill_random(random, sizeof random);
auth_.begin(random);
requests_ = bytesIn_ = bytesOut_ = 0;
last_[0] = 0;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.stack_size = 8192;
config.max_open_sockets = 4;
config.lru_purge_enable = true; // a phone's browser opens more connections than it closes
config.max_uri_handlers = 8;
config.recv_wait_timeout = 10;
config.send_wait_timeout = 10;
httpd_handle_t server = nullptr;
if (httpd_start(&server, &config) != ESP_OK) return "The server couldn't start";
const httpd_uri_t routes[] = {
{"/", HTTP_GET, page, this}, {"/api/login", HTTP_POST, login, this}, {"/api/list", HTTP_GET, list, this},
{"/dl", HTTP_GET, download, this}, {"/up", HTTP_PUT, upload, this}, {"/api/delete", HTTP_POST, remove, this},
{"/api/mkdir", HTTP_POST, makeFolder, this},
};
for (const auto& r : routes) httpd_register_uri_handler(server, &r);
server_ = server;
console.printf("share: on at %s\n", url().c_str());
return "";
}
void WebShare::stop() {
if (!server_) return;
httpd_stop(static_cast<httpd_handle_t>(server_));
server_ = nullptr;
uint8_t zero[4] = {0, 0, 0, 0};
auth_.begin(zero); // nobody is logged in any more
console.println("share: off");
}
} // namespace roro
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <atomic>
#include <string>
#include "services/file_ops.h"
#include "services/storage_service.h"
#include "services/wifi_service.h"
#include "share_rules.h"
namespace roro {
// Sharing the SD card with a browser on the same network (issue #88): a small HTTP server with
// one page and a few requests behind it: list, download, upload, new folder, delete. It runs only
// between start() and stop(), which the Storage App's "Share" screen calls on opening and
// closing. Whoever has typed the code that screen shows may use it; nothing is encrypted.
//
// The server has its own task. Every access to the card is handed to the storage task, a piece
// at a time, as everywhere else.
class WebShare {
public:
WebShare(StorageService& storage, FileOps& ops, WifiService& wifi) : storage_(storage), ops_(ops), wifi_(wifi) {}
std::string start(); // "" or why it can't
void stop();
bool running() const { return server_ != nullptr; }
std::string url() const; // http://<address>/
const std::string& code() const { return auth_.code(); }
// For the screen.
uint32_t requests() const { return requests_; }
uint32_t bytesIn() const { return bytesIn_; }
uint32_t bytesOut() const { return bytesOut_; }
std::string last() const; // the last thing asked for
// Used by the request handlers (web_share.cpp).
StorageService& storage() { return storage_; }
FileOps& ops() { return ops_; }
files::ShareAuth& auth() { return auth_; }
void note(const char* what, const std::string& path);
void counted(uint32_t in, uint32_t out) {
bytesIn_ += in;
bytesOut_ += out;
}
private:
StorageService& storage_;
FileOps& ops_;
WifiService& wifi_;
files::ShareAuth auth_;
void* server_ = nullptr; // httpd_handle_t
std::atomic<uint32_t> requests_{0}, bytesIn_{0}, bytesOut_{0};
char last_[56] = "";
};
} // namespace roro
+69
View File
@@ -0,0 +1,69 @@
#pragma once
// The page a phone's browser gets (issue #88): one file, no other request but the API's. Kept
// small: it is sent from flash as it is.
namespace roro {
inline constexpr char kWebSharePage[] = R"HTML(<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>roro9stack files</title>
<style>
:root{color-scheme:dark light;--bg:#000;--fg:#fff;--mu:#9ab;--ac:#2cf;--ln:#345;--wa:#fa3}
@media (prefers-color-scheme:light){:root{--bg:#fff;--fg:#012;--mu:#567;--ac:#06a;--ln:#bcd;--wa:#b50}}
*{box-sizing:border-box}body{margin:0;font:16px/1.4 system-ui,sans-serif;background:var(--bg);color:var(--fg)}
main{max-width:720px;margin:0 auto;padding:12px}h1{font-size:18px;margin:4px 0 12px}
button,input,.btn{font:inherit;min-height:44px;padding:0 14px;border:1px solid var(--ln);background:none;color:inherit;border-radius:6px}
button,.btn{cursor:pointer}.main{background:var(--ac);color:#000;border-color:var(--ac)}
#crumbs{margin:8px 0;word-break:break-all}#crumbs a{color:var(--ac);text-decoration:none;padding:6px 2px;display:inline-block}
ul{list-style:none;margin:0;padding:0}li{display:flex;align-items:center;gap:8px;border-top:1px solid var(--ln);min-height:48px}
li a{flex:1;color:inherit;text-decoration:none;padding:10px 0;word-break:break-all}li.d a{color:var(--ac)}
li small{color:var(--mu);white-space:nowrap}li button{min-height:36px;padding:0 10px;color:var(--mu)}
#bar{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}#msg{color:var(--wa);min-height:24px;margin:8px 0}
progress{width:100%}.hide{display:none}#login input{width:9em;font-size:22px;letter-spacing:.15em;text-align:center}
</style></head><body><main>
<h1>roro9stack: the SD card</h1>
<form id="login" class="hide"><p>The code on the device's screen:</p>
<input id="code" inputmode="numeric" autocomplete="off" maxlength="7" autofocus> <button class="main">Open</button></form>
<div id="app" class="hide">
<div id="crumbs"></div>
<div id="bar"><label class="btn main" style="display:inline-flex;align-items:center">Upload files<input id="pick" type="file" multiple class="hide"></label>
<button id="mk">New folder</button></div>
<progress id="prog" class="hide" max="100" value="0"></progress>
<ul id="list"></ul></div>
<p id="msg"></p>
</main><script>
const $=i=>document.getElementById(i),E=encodeURIComponent;let cwd='/';
const say=t=>{$('msg').textContent=t||''};
const join=(d,n)=>d=='/'?'/'+n:d+'/'+n;
const size=s=>s<1024?s+' B':s<1048576?(s/1024).toFixed(1)+' KB':(s/1048576).toFixed(1)+' MB';
function show(app){$('login').classList.toggle('hide',app);$('app').classList.toggle('hide',!app)}
async function call(u,o){const r=await fetch(u,o);if(r.status==401){show(false);throw new Error('The code, please')}
if(!r.ok)throw new Error(await r.text()||('Error '+r.status));return r}
async function login(code){const r=await fetch('/api/login',{method:'POST',body:'code='+E(code)});
if(r.ok){show(true);list('/')}else say(await r.text())}
async function list(p){try{const j=await(await call('/api/list?path='+E(p))).json();cwd=j.path;say(j.more?'Only the first '+j.items.length+' are shown':'');
const c=$('crumbs');c.textContent='';let at='';const parts=['/'].concat(cwd.split('/').filter(x=>x));
parts.forEach((n,i)=>{at=i?join(at||'/',n):'/';const a=document.createElement('a');a.href='#';a.textContent=i?n:'SD card';const to=at;a.onclick=e=>{e.preventDefault();list(to)};
if(i)c.append(' / ');c.append(a)});
const ul=$('list');ul.textContent='';j.items.sort((a,b)=>b.d-a.d||a.n.localeCompare(b.n));
if(!j.items.length){const li=document.createElement('li');li.innerHTML='<small>Nothing here</small>';ul.append(li)}
for(const it of j.items){const li=document.createElement('li'),a=document.createElement('a'),s=document.createElement('small'),b=document.createElement('button'),full=join(cwd,it.n);
a.textContent=it.n+(it.d?'/':'');if(it.d){li.className='d';a.href='#';a.onclick=e=>{e.preventDefault();list(full)}}else{a.href='/dl?path='+E(full);a.download=it.n;s.textContent=size(it.s)}
b.textContent='Delete';b.onclick=async()=>{if(!confirm('Delete '+it.n+'?'))return;try{await call('/api/delete?path='+E(full),{method:'POST'});list(cwd)}catch(e){say(e.message)}};
li.append(a,s,b);ul.append(li)}}catch(e){say(e.message)}}
function put(f,replace){return new Promise((ok,no)=>{const x=new XMLHttpRequest();x.open('PUT','/up?path='+E(join(cwd,f.name))+(replace?'&replace=1':''));
x.upload.onprogress=e=>{if(e.lengthComputable)$('prog').value=100*e.loaded/e.total};
x.onload=()=>x.status==200?ok():x.status==409&&!replace&&confirm(f.name+' is there already. Replace it?')?put(f,1).then(ok,no):x.status==409?ok():no(new Error(x.responseText||'Error '+x.status));
x.onerror=()=>no(new Error('The connection was lost'));x.send(f)})}
$('pick').onchange=async e=>{const fs=[...e.target.files];$('prog').classList.remove('hide');
try{let n=0;for(const f of fs){say('Uploading '+f.name+' ('+(++n)+' of '+fs.length+')');$('prog').value=0;await put(f)}say('')}catch(err){say(err.message)}
$('prog').classList.add('hide');e.target.value='';list(cwd)};
$('mk').onclick=async()=>{const n=prompt('The new folder\'s name');if(!n)return;try{await call('/api/mkdir?path='+E(join(cwd,n)),{method:'POST'});list(cwd)}catch(e){say(e.message)}};
$('login').onsubmit=e=>{e.preventDefault();login($('code').value)};
async function start(){const h=location.hash.slice(1);if(h){history.replaceState(null,'','/');await login(h)}
else{const r=await fetch('/api/list?path=%2F');if(r.ok){show(true);list('/')}else show(false)}}
onhashchange=start;start();
</script></body></html>
)HTML";
} // namespace roro
+17
View File
@@ -231,6 +231,22 @@ void test_fn_h_is_help_in_both_modes() {
} }
} }
// Issue #83: Fn+p everywhere.
void test_fn_p_is_a_screenshot_in_both_modes() {
for (bool typing : {true, false}) {
KeyMapper m;
m.setTextEntry(typing);
auto ev = press(m, withFn({'p'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(static_cast<int>(Key::Screenshot), static_cast<int>(ev[0].key));
release(m);
ev = press(m, chars({'p'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
TEST_ASSERT_EQUAL('p', ev[0].ch);
}
}
void test_plain_h_still_types() { void test_plain_h_still_types() {
for (bool typing : {true, false}) { for (bool typing : {true, false}) {
KeyMapper m; KeyMapper m;
@@ -278,6 +294,7 @@ int main() {
RUN_TEST(test_other_characters_still_type_when_not_typing); RUN_TEST(test_other_characters_still_type_when_not_typing);
RUN_TEST(test_shifted_arrow_keys_type_their_symbols_when_not_typing); RUN_TEST(test_shifted_arrow_keys_type_their_symbols_when_not_typing);
RUN_TEST(test_fn_h_is_help_in_both_modes); RUN_TEST(test_fn_h_is_help_in_both_modes);
RUN_TEST(test_fn_p_is_a_screenshot_in_both_modes);
RUN_TEST(test_plain_h_still_types); RUN_TEST(test_plain_h_still_types);
RUN_TEST(test_question_mark_is_help_only_when_not_typing); RUN_TEST(test_question_mark_is_help_only_when_not_typing);
return UNITY_END(); return UNITY_END();
+101
View File
@@ -0,0 +1,101 @@
#include <unity.h>
#include <string>
#include "share_rules.h"
using namespace roro::files;
void setUp() {}
void tearDown() {}
void test_what_a_request_asks_for() {
TEST_ASSERT_EQUAL_STRING("/notes/my list.txt", urlDecode("%2Fnotes%2Fmy%20list.txt").c_str());
TEST_ASSERT_EQUAL_STRING("a+b", urlDecode("a+b").c_str());
TEST_ASSERT_EQUAL_STRING("100%", urlDecode("100%").c_str());
TEST_ASSERT_EQUAL_STRING("%zz", urlDecode("%zz").c_str());
TEST_ASSERT_EQUAL_STRING("\xC3\xA9t\xC3\xA9", urlDecode("%C3%A9t%C3%a9").c_str());
std::string v;
TEST_ASSERT_TRUE(queryParam("path=%2Fnotes&replace=1", "path", v));
TEST_ASSERT_EQUAL_STRING("/notes", v.c_str());
TEST_ASSERT_TRUE(queryParam("path=%2Fnotes&replace=1", "replace", v));
TEST_ASSERT_EQUAL_STRING("1", v.c_str());
TEST_ASSERT_FALSE(queryParam("xpath=1&pathx=2", "path", v));
TEST_ASSERT_FALSE(queryParam("", "path", v));
TEST_ASSERT_TRUE(queryParam("a=&path=", "path", v));
TEST_ASSERT_EQUAL_STRING("", v.c_str());
TEST_ASSERT_EQUAL_STRING("abc123", cookieValue("theme=dark; s=abc123; x=1", "s").c_str());
TEST_ASSERT_EQUAL_STRING("abc123", cookieValue("s=abc123", "s").c_str());
TEST_ASSERT_EQUAL_STRING("", cookieValue("ss=abc123; xs=1", "s").c_str());
TEST_ASSERT_EQUAL_STRING("", cookieValue("", "s").c_str());
}
void test_paths_a_browser_may_name() {
TEST_ASSERT_EQUAL_STRING("", checkSharePath("/").c_str());
TEST_ASSERT_EQUAL_STRING("", checkSharePath("/notes/my list (2).txt").c_str());
TEST_ASSERT_EQUAL_STRING("", checkSharePath("/gemini/saved/\xC3\xA9t\xC3\xA9.gmi").c_str());
for (const char* bad : {"", "notes", "/notes/", "/notes/../wifi", "/..", "/a//b", "/a/./b", "/a\\b", "/a/b\n", "/a:b", "/what?", "/a*"})
TEST_ASSERT_TRUE_MESSAGE(!checkSharePath(bad).empty(), bad);
TEST_ASSERT_TRUE(!checkSharePath("/" + std::string(300, 'a')).empty());
}
void test_json() {
TEST_ASSERT_EQUAL_STRING("\"plain\"", jsonString("plain").c_str());
TEST_ASSERT_EQUAL_STRING("\"a \\\"b\\\" \\\\ c\"", jsonString("a \"b\" \\ c").c_str());
TEST_ASSERT_EQUAL_STRING("\"tab\\u0009\"", jsonString("tab\t").c_str());
TEST_ASSERT_EQUAL_STRING("\"\xC3\xA9\"", jsonString("\xC3\xA9").c_str());
ShareListing empty("/");
TEST_ASSERT_EQUAL_STRING("{\"path\":\"/\",\"items\":[],\"more\":false}", empty.json(false).c_str());
ShareListing l("/notes");
l.add("a \"b\".txt", 12, false, 1791400000);
l.add("old", 0, true, 0);
TEST_ASSERT_EQUAL_size_t(2, l.count());
TEST_ASSERT_EQUAL_STRING("{\"path\":\"/notes\",\"items\":[{\"n\":\"a \\\"b\\\".txt\",\"s\":12,\"d\":0,\"t\":1791400000},{\"n\":\"old\",\"s\":0,\"d\":1,\"t\":0}],\"more\":true}",
l.json(true).c_str());
}
void test_the_code_and_the_token() {
ShareAuth auth;
std::string token;
const uint8_t r16[16] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff};
TEST_ASSERT_TRUE(auth.login("000000", 0, r16, token) == ShareAuth::Result::Locked); // not started: nobody gets in
TEST_ASSERT_FALSE(auth.allowed(""));
const uint8_t r4[4] = {0x00, 0x00, 0x30, 0x39}; // 12345
auth.begin(r4);
TEST_ASSERT_EQUAL_STRING("012345", auth.code().c_str());
TEST_ASSERT_FALSE(auth.allowed(""));
TEST_ASSERT_TRUE(auth.login("12345", 1000, r16, token) == ShareAuth::Result::Wrong); // the leading zero counts
TEST_ASSERT_TRUE(token.empty());
TEST_ASSERT_TRUE(auth.login("012 345", 2000, r16, token) == ShareAuth::Result::Ok); // typed as the screen groups it
TEST_ASSERT_EQUAL_STRING("00112233445566778899aabbccddeeff", token.c_str());
TEST_ASSERT_TRUE(auth.allowed(token));
TEST_ASSERT_FALSE(auth.allowed(token + "0"));
TEST_ASSERT_FALSE(auth.allowed("00112233445566778899aabbccddeef0"));
// Sharing started again: a new code, and yesterday's browser is out.
const uint8_t again[4] = {0xFF, 0xFF, 0xFF, 0xFF};
auth.begin(again);
TEST_ASSERT_EQUAL_size_t(6, auth.code().size());
TEST_ASSERT_FALSE(auth.allowed(token));
}
void test_five_wrong_codes_close_it_for_a_minute() {
ShareAuth auth;
const uint8_t r4[4] = {0, 0, 0, 7};
const uint8_t r16[16] = {0};
auth.begin(r4);
std::string token;
for (int i = 0; i < 4; i++) TEST_ASSERT_TRUE(auth.login("999999", 1000, r16, token) == ShareAuth::Result::Wrong);
TEST_ASSERT_TRUE(auth.login("999999", 1000, r16, token) == ShareAuth::Result::Locked);
TEST_ASSERT_TRUE(auth.login("000007", 30000, r16, token) == ShareAuth::Result::Locked); // the right one too
TEST_ASSERT_TRUE(auth.login("000007", 62000, r16, token) == ShareAuth::Result::Ok);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_what_a_request_asks_for);
RUN_TEST(test_paths_a_browser_may_name);
RUN_TEST(test_json);
RUN_TEST(test_the_code_and_the_token);
RUN_TEST(test_five_wrong_codes_close_it_for_a_minute);
return UNITY_END();
}