Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3fe618c7f | ||
|
|
d7092ee6d6 | ||
|
|
63c2da8138 | ||
|
|
057af773e4 | ||
|
|
6b02cd3d5f | ||
|
|
c35bc47693 | ||
|
|
4cdb4c342f | ||
|
|
1c9f90f92e | ||
|
|
2c18762614 | ||
|
|
ae25cf0be2 | ||
|
|
834c6eb0f2 | ||
|
|
5823584bfd | ||
|
|
35f0d5959c | ||
|
|
dd4e6c31ed | ||
|
|
de8af6ed92 | ||
|
|
10c5291e15 | ||
|
|
12c88c98d3 | ||
|
|
55c9ad2eb4 |
@@ -4,7 +4,8 @@
|
||||
# A pull request: the same tests and coverage, then the firmware (from the build cache).
|
||||
# A branch's pushes run nothing by themselves: its pull request runs, once.
|
||||
# A tag v*: the tests, then the firmware built once, clean, signed and published as a
|
||||
# Gitea release.
|
||||
# Gitea release. The site is then rebuilt: its home page and Downloads name
|
||||
# the latest release when they are built (issue #79).
|
||||
# Run by hand: the release of a tag that exists already (the ones from before CI).
|
||||
#
|
||||
# The job runs in a plain Python image, as scripts/ci.sh does on a developer's machine, with the
|
||||
@@ -153,3 +154,14 @@ jobs:
|
||||
GITEA_REPO: ${{ github.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: scripts/release_publish.py dist
|
||||
|
||||
# The site names the latest release on its home page and lists them all on Downloads, both
|
||||
# read when it is built: so it is rebuilt now (issue #79, as the Site workflow does).
|
||||
- name: Refresh the site
|
||||
if: steps.release.outputs.tag != ''
|
||||
env:
|
||||
SITE_DEPLOY_KEY: ${{ secrets.SITE_DEPLOY_KEY }}
|
||||
SITE_DEPLOY_HOST: ${{ secrets.SITE_DEPLOY_HOST }}
|
||||
SITE_DEPLOY_USER: ${{ secrets.SITE_DEPLOY_USER }}
|
||||
SITE_DEPLOY_KNOWN_HOSTS: ${{ secrets.SITE_DEPLOY_KNOWN_HOSTS }}
|
||||
run: scripts/site_refresh.sh
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# The project site (docs/milestones/W1.md): built with Zola to see that it builds and that its pages
|
||||
# are sound. Publishing is the maintainer's: the web server pulls main and runs `zola build`.
|
||||
# are sound. After a push to main it is then published: the job asks the web server, over SSH, to
|
||||
# pull main and rebuild (issue #79, scripts/site_refresh.sh). The key it holds can run that one
|
||||
# command there and nothing else; the server, the user and the keys are secrets, not in this file.
|
||||
#
|
||||
# It runs when the site, or a document the site is built from, changes (a pull request, or a push to
|
||||
# main); the firmware workflow (ci.yml) skips a change that touches only these files. A change that
|
||||
@@ -9,9 +11,9 @@ name: Site
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', 'lib/core/src/app_keys.h', '.gitea/workflows/site.yml']
|
||||
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', 'lib/core/src/app_keys.h', '.gitea/workflows/site.yml', 'scripts/site_refresh.sh']
|
||||
pull_request:
|
||||
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', 'lib/core/src/app_keys.h', '.gitea/workflows/site.yml']
|
||||
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', 'lib/core/src/app_keys.h', '.gitea/workflows/site.yml', 'scripts/site_refresh.sh']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -51,3 +53,14 @@ jobs:
|
||||
|
||||
- name: Check the pages
|
||||
run: python3 site/tools/check_site.py /tmp/site-out
|
||||
|
||||
# Only what has been merged, and only once it has built and passed the checks above. A pull
|
||||
# request never gets here, and the secrets are given to this step alone.
|
||||
- name: Publish the site
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
SITE_DEPLOY_KEY: ${{ secrets.SITE_DEPLOY_KEY }}
|
||||
SITE_DEPLOY_HOST: ${{ secrets.SITE_DEPLOY_HOST }}
|
||||
SITE_DEPLOY_USER: ${{ secrets.SITE_DEPLOY_USER }}
|
||||
SITE_DEPLOY_KNOWN_HOSTS: ${{ secrets.SITE_DEPLOY_KNOWN_HOSTS }}
|
||||
run: scripts/site_refresh.sh
|
||||
|
||||
@@ -136,9 +136,12 @@ 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.
|
||||
|
||||
**`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:
|
||||
|
||||
- **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 (up to 16 KB, 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).
|
||||
- **Pictures** (`.png`, `.jpg`, `.bmp`, `.gif`; issue #45): shrunk to fit the screen, or at their own size with Enter, the arrows then moving half a screen at a time; `i` gives the size in pixels. Dithered to the screen's 256 colours, decoded straight into the screen's buffer with no copy in memory, on the storage task so the keys keep working (12 megapixels of JPEG: 7 s). A GIF shows its first picture. A progressive JPEG or an interlaced PNG opens as hex, with the reason.
|
||||
- **Captures** (`.pcap`): the packets as the LoRa Scanner lists them; Enter shows one with its Meshtastic header and bytes.
|
||||
- **Tracks** (`.gpx`): the number of points, the start, the duration and the distance.
|
||||
- **Update Files** (`.ota`): the version, and whether the file would install: it's checked as an install checks it (signature and contents) without writing anything. Enter then installs it.
|
||||
@@ -150,11 +153,11 @@ At the top of the card the last row, **Maintenance** (also `m`), holds the card'
|
||||
|
||||
The Notes App (docs/milestones/F1.md) keeps plain text notes in `/notes` on the SD card. The list shows each note's first line and its date, newest first; `s` switches to by file name. `n` starts a note, Enter opens one, `r` renames its file, `d` deletes it after asking.
|
||||
|
||||
In the editor, type. Enter starts a line, Del deletes backwards, Fn with the arrows moves the cursor through the wrapped text, Ctrl+A and Ctrl+E go to the start and the end of the line, Tab types two spaces, and the Compose Key gives accents as everywhere. **There's no save key:** the note is written five seconds after the last key, on Back, on leaving the App, when the screen turns off and before the device powers off. The top line says "typing" or "saved". Each save writes a temporary file and then puts it in the note's place, so a power cut costs a few seconds of typing and never the note; if a save was cut short, opening the note offers its copy back.
|
||||
In the editor, type. Enter starts a line, Del deletes backwards, Fn with the arrows moves the cursor through the wrapped text, Ctrl+A and Ctrl+E go to the start and the end of the line, Ctrl with Fn+Up and Fn+Down to the start and the end of the note, Tab types two spaces, and the Compose Key gives accents as everywhere. **There's no save key:** the note is written five seconds after the last key, on Back, on leaving the App, when the screen turns off and before the device powers off. The top line says "typing" or "saved". Each save writes a temporary file and then puts it in the note's place, so a power cut costs a few seconds of typing and never the note; if a save was cut short, opening the note offers its copy back.
|
||||
|
||||
A new note has no file until something is typed; its file is then named after its first line (`shopping-list.txt`), or `note-<date>-<time>.txt`.
|
||||
|
||||
A note holds up to 16 KB while it's edited. A bigger text file opens read-only in the Storage App (editing any size is issue #47). The Storage App's text viewer has `e` to edit a file with the same editor, anywhere on the card, unless the file is read-only.
|
||||
**A note can be any size** (issue #47): the editor keeps a window of about 8 KB around the cursor in memory and the rest on the card, so a megabyte opens as fast as a line and uses the same 17 KB. Up to 64 KB a save rewrites the file. Above, the five-second save writes only what changed to a side file, `<note>.edit`, and the file itself is rewritten when the note is left, with a progress bar (about 450 KB a second). After a power cut, opening the note picks the edit up where it was saved. Saving needs room on the card for a second copy. The Storage App's text viewer has `e` to edit a file with the same editor, anywhere on the card, unless the file is read-only.
|
||||
|
||||
## Shell
|
||||
|
||||
@@ -167,7 +170,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
|
||||
| Command | Effect |
|
||||
|---|---|
|
||||
| `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) |
|
||||
| `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) |
|
||||
| `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) |
|
||||
|
||||
@@ -98,7 +98,7 @@ Plain text notes on the SD card, written on the device. Q30 settled the base: `.
|
||||
| Q141 | A **Notes** App in the Launcher. One row per note: its first line as the title, then the date. Newest first; `s` switches to by name. `n` new, Enter opens, `d` deletes after a confirmation, `r` renames the file. |
|
||||
| Q142 | A new note's file name is never typed: it comes from the first line when the note is first saved (`shopping-list.txt`), or `note-20261006-0919.txt` if that line is empty. It doesn't change afterwards unless the note is renamed. |
|
||||
| Q143 | **Autosave, no "discard changes?" prompt:** five seconds after the last key, on leaving the note or the App, and when the screen turns off. A save writes a temporary file and renames it over the note, so a power cut loses the last few seconds at most. A temporary file left behind is offered back at the next open. |
|
||||
| Q144 | The whole note is in memory while it's edited, up to **16 KB**. A bigger text file opens read-only in the Storage App's viewer. The App refuses to open below the memory floors (Q86). **Editing files of any size must come in a later release: issue #47.** |
|
||||
| Q144 | The whole note is in memory while it's edited, up to **16 KB**. A bigger text file opens read-only in the Storage App's viewer. The App refuses to open below the memory floors (Q86). *(Lifted by issue #47: see "Notes of any size" below.)* **Editing files of any size must come in a later release: issue #47.** |
|
||||
| Q145 | The editor wraps at spaces, 38 columns by 8 rows, with a line for the name and the state. Enter is a new line, Del deletes backwards, Fn+arrows move (the Text Entry rule), Ctrl+A and Ctrl+E go to the start and the end of the line, Tab types two spaces, Back saves and returns. The Compose Key works as elsewhere. |
|
||||
| Q146 | The Storage App's text viewer gets `e`: edit this file with the same editor, for a text file up to 16 KB that isn't read-only. That lifts Q135 without Apps opening each other (#43 stays). |
|
||||
| Q147 | The list is flat: the files directly in `/notes`. Sub-folders are reached through the Storage App. |
|
||||
@@ -159,3 +159,177 @@ Test notes were made in `/notes` and removed afterwards; the folder is left, emp
|
||||
**Not checked:** accents through the Compose Key and Ctrl+A / Ctrl+E (the remote `key` command can't send them; the model's tests cover both), the power button's save (it needs a hand on the device), a missing card, and how typing feels on the keyboard itself.
|
||||
|
||||
**One slip during the checks:** a key sequence sent right after a restart opened IRC instead of Notes, and the test letters went into IRC's input line. Nothing was sent: the line was cleared and the App left. IRC connected to Libera as it does when opened.
|
||||
|
||||
## Notes of any size (issue #47)
|
||||
|
||||
Q144 held the whole note in memory and stopped at 16 KB, for the first version only. This lifts it: the editor opens a text file whatever its size.
|
||||
|
||||
### Decisions (design round 2026-10-07)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q223 | **The note is the file on the card plus one window in memory.** The window is the `NoteText` of before, up to 16 KB around the cursor; the rest is a list of pieces: runs of the file, and runs of a side file. The cursor leaving the window writes it to the side file if it was changed, and loads the next. Typing never fills a note: a full window is written away and loaded smaller. |
|
||||
| Q224 | **The five-second save:** up to 64 KB it rewrites the file, as before (about 150 ms). Above, it appends the window and the list of pieces to `<note>.edit`: 8 KB or so, whatever the note's size. "saved" means "on the card" either way. |
|
||||
| Q225 | **The file itself is rewritten on leaving the note** (Back, Home, another App), with a progress bar. The screen turning off and the device powering off write the side file only: powering off never waits. |
|
||||
| Q226 | **After a power cut, opening the note picks the edit up** where it was last saved, without a question, and says so. Until then the file has the old text for anything else that reads it. |
|
||||
| Q227 | If the file was changed elsewhere meanwhile, the side file no longer fits it: it is **kept as `<note>.edit.lost`** and the editor says so. Typed text is never deleted without a word. |
|
||||
| Q228 | **No limit but the card:** a note over 16 KB needs room for a second copy to be opened for editing. No warning for a big file; the progress bar on leaving tells the cost. |
|
||||
| Q229 | A side file over 1 MB, or a list of over 256 pieces, makes the next save a rewrite. |
|
||||
| Q230 | **CRLF becomes LF** (Q148) for a long file too: in the window as it is read, and in the rest of the file as the rewrite streams it, so a saved file is never of both kinds. |
|
||||
| Q231 | **One path.** A 16 KB note is the case with no pieces: there is no second editor for small notes. |
|
||||
| Q232 | Notes, and `e` in the Storage App's viewer, which no longer says "Too big to edit". |
|
||||
|
||||
### As built
|
||||
|
||||
- **`NoteDocument`** (`lib/notes/src/note_document.h`, host-tested against a card in memory) is the list of pieces, the window's moves, the side file and the recovery. `NoteText` is unchanged but for being refilled.
|
||||
- **The window moves** when the cursor comes within 2 KB of an end of it that isn't an end of the note: it is then 4 KB on each side of the cursor. It starts where a line starts on screen whenever that can be known (after a newline, or where the window before had a line start), so the same text wraps the same from one window to the next, and never in the middle of a character. The cursor keeps its row on screen.
|
||||
- **Looking writes nothing:** a window that wasn't changed goes back as the pieces it was read from.
|
||||
- **The side file** starts with a line of text, the note's size and checksums of its first and last kilobyte, which is how a file changed elsewhere is told. After that, text that left a window, and snapshots of the list of pieces, each with its checksum. The newest snapshot that checks out is the note as last saved; anything after it is ignored.
|
||||
- **The rewrite** streams the pieces and the window into `<note>.tmp`, checks its size, then writes a mark at the end of the side file: from that mark on, the rewrite counts as done, and opening the note finishes it whatever was cut (remove the old file, rename, remove the side file). Before the mark, the note and its side file are still the truth and the temporary file is dropped.
|
||||
- **On the device** the card is reached through an adapter that keeps the file being read and the file being appended to open between calls; every call runs on the storage task while the main loop waits. The rewrite runs in steps of 64 KB with the progress drawn between them.
|
||||
- **The Notes list** doesn't show `.edit` and `.edit.lost` files, and a note's side file is deleted and renamed with it.
|
||||
- **Ctrl with Fn+Up and Fn+Down** go to the start and the end of the note.
|
||||
- **`key ctrl-down`**: the consoles' `key` command takes `ctrl-`, `alt-` and `shift-`, which these checks needed. It also lets the checks S1 couldn't make (Ctrl+b, the Alt scroll) be made.
|
||||
- **Cost:** 15 KB of flash. Memory with a note open is what it was: 17.5 KB, for 62 bytes or for 1.2 MB.
|
||||
|
||||
### Host tests (15, `test/test_note_document`)
|
||||
|
||||
A walk down 3,000 lines and back up through the windows; start and end; an edit in the middle rewritten into the file; 48 KB typed into a new note; a journal picked up after a cut; **a cut at every 997th byte of a sequence of two saves and a rewrite**, after which the note is always one of the three texts it should be, what was reported saved is there, and no stray file is left; a file changed elsewhere; CRLF; windows on text with no space and no newline, made of 2, 3 and 4-byte characters; a full card; and **36,000 random keys** (typing, deleting, moving, jumping, saving, power cuts) on six notes of 30 to 130 KB, compared with a plain string after every key.
|
||||
|
||||
### Checks on the device (2026-10-07, driven over the Debug Console)
|
||||
|
||||
Test notes were copied to `/notes` and removed afterwards; the note that was already there was not touched.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| A 36 KB note | Opens (it was refused before). Two letters at the top, 400 lines down across the windows, four more: the file fetched back is exactly that, and no other file is left |
|
||||
| A 1.2 MB note | Opens at once. Free memory 104.2 KB before, 86.7 KB with it open |
|
||||
| Its five-second save | `zz-big.txt.edit`, 4 KB; the note's file untouched |
|
||||
| Ctrl with Down, Ctrl with Up | The end and the start, as fast as any key |
|
||||
| A restart with unsaved keys | "Your unsaved changes are back", the cursor where it was, the unsaved keys gone and nothing else |
|
||||
| Leaving it | The progress bar, then one file: **1.2 MB rewritten in 2.6 s**. Fetched back: the original with what was typed at both ends, byte for byte |
|
||||
| A restart in the middle of that rewrite | The note, its side file and an empty `.tmp` remain; opening picks the edit up, leaving rewrites it, the result is right |
|
||||
| A new note | No file until typed in, then `zz-test-note.txt` from its first line |
|
||||
| `e` in the Storage App on the 1.2 MB file | The same editor; edited and rewritten |
|
||||
| The Notes list | Side files are not listed as notes |
|
||||
|
||||
**Not checked:** the power button's path (side file only), the screen turning off, a card pulled while editing, and memory with IRC connected, which wasn't connected for these checks: the editor's own use hasn't changed, and it still refuses to open without a free block of 24 KB. The real keyboard's Ctrl with Fn and the arrows. A file of tens of megabytes. Renaming or deleting a note from the Storage App leaves its side file behind.
|
||||
|
||||
**Measured against what was said:** the first build rewrote 1.2 MB in 3.5 to 4.5 s, with 2 KB blocks. With 4 KB blocks it is 2.6 s, about 450 KB a second, which is what the card gives a plain copy.
|
||||
|
||||
## Pictures in the Storage App (issue #45)
|
||||
|
||||
Q139 left images out: the firmware wrote none. Since the Shell's `screenshot` it does.
|
||||
|
||||
### Decisions (design round 2026-10-07)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q233 | **PNG, JPEG, BMP and GIF.** A GIF shows its first picture; it doesn't move. |
|
||||
| Q234 | *Revised while building.* **Our own PNG decoder**, a row at a time, with the window the file's compression asks for: 32 KB at most. The plan was the display library's, which takes 44 KB in one block: after one picture the largest free block was 43 to 47 KB, and every second PNG was refused. **The firmware's own screenshots** are read with no decoding at all: they are stored uncompressed, each byte already a colour of the screen. |
|
||||
| Q235 | **The picture is decoded once, straight into the screen's buffer, and left there.** No copy in memory (it would be up to 30 KB). `App::retainsContent()` tells the screen not to clear the App's part; `contentLost()` tells the App that it was cleared after all, or that a Toast or the help panel drawn over it has gone: then it is decoded again. |
|
||||
| Q236 | **Shrunk to fit; Enter shows it at its own size**, the arrows then moving half a screen. A picture smaller than the screen sits in the middle at its size. |
|
||||
| Q237 | **Ordered dithering** to the screen's 256 colours (a 4 x 4 pattern). A colour the screen has exactly comes out as itself wherever it lands, so a screenshot isn't touched. |
|
||||
| Q238 | Shrinking takes, for each pixel of the screen, the first of the picture's that falls on it. No averaging: there is nowhere to keep the sums. Thin lines break up; a JPEG looks better, its decoder halving it up to three times first. |
|
||||
| Q239 | **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. The rotation a camera stores in the file is ignored. |
|
||||
| Q240 | *Revised while building.* **Decoding runs on the storage task while the main loop goes on.** The picture appears as it comes, and anything that needs the screen back stops the decoding first. The plan was to wait for it, behind a "Decoding..." line: 12 megapixels took longer than the watchdog allows the main loop to stand still, and the device restarted. |
|
||||
| Q241 | The Storage App: Enter on `.png`, `.jpg`, `.jpeg`, `.bmp`, `.gif`, or on a file with no known extension whose first bytes say what it is. Tab gives the hex. `i`, and opening, show the size in pixels on the last line for three seconds. |
|
||||
| Q242 | Not in this one: animation, opening a picture from the Gemini App, a slideshow. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/files/src/image_file.h`** (host-tested): what a file is and how big, where each pixel lands (`ImageFrame`, `ImageMap`), the dithering, and readers for BMP and GIF that hand their pixels on as they get them. **`png_reader.h`**: the PNG decoder, with its own inflate: every colour type and bit depth, palettes with transparency. Transparent pixels are left as the background.
|
||||
- **`ImagePane`** (`src/apps/image_pane`) is the view. One decoding is a `Job` shared with the storage task; `cancel()` flags it and waits behind it in the task's queue, which is how the screen is known to be free again.
|
||||
- **JPEG is the one decoder that isn't ours:** the display library's TJpgDec, with its 3.9 KB pool. It shrinks by 2, 4 or 8 while decoding, which is why a photograph is possible at all.
|
||||
- **A decoder stops early** once the rest of the file is below the screen (a picture at its own size), and a BMP's rows that aren't shown aren't read.
|
||||
- **The note** on the last line is written over the picture; the strip under it (2.6 KB) is kept and put back, so showing it costs no decoding.
|
||||
- **A BMP is read in the order its rows are stored**, last row first: reading it top to bottom meant going back through the file for every row, a second for 135 rows.
|
||||
- **Cost:** 21 KB of flash. Nothing while no picture is shown.
|
||||
|
||||
### Measured on the device
|
||||
|
||||
| Picture | Fitted | Its own size |
|
||||
|---|---|---|
|
||||
| A screenshot of ours, 240 x 135 | 80 ms | 78 ms |
|
||||
| PNG, 800 x 600 | 855 ms | 575 ms |
|
||||
| PNG with transparency, 800 x 600 | 1,098 ms | |
|
||||
| JPEG, 800 x 600 | 305 ms | |
|
||||
| JPEG, 4000 x 3000 (2.6 MB) | 6.9 s | 7.7 s (the middle of it) |
|
||||
| GIF, 800 x 600 | 642 ms | |
|
||||
| BMP, 800 x 600 (1.4 MB) | 991 ms | |
|
||||
| BMP, 240 x 135 | 124 ms | |
|
||||
|
||||
Free memory fell to 48.8 KB at the lowest while a PNG was decoded, from 104 KB. The storage task's stack: 3.2 KB never used, of 6.
|
||||
|
||||
### Host tests (20, `test/test_image_file` and `test/test_png_reader`)
|
||||
|
||||
The pictures in them were made with Pillow, so the readers are checked against an encoder that isn't ours: GIFs plain, interlaced, transparent and long enough for the codes to reach 12 bits; BMPs of 8 and 24 bits; PNGs of every kind (colour, with alpha, palette of 8 and 4 bits with a transparent entry, greys of 1, 8 and 16 bits, grey with alpha, not compressed, and one that refers 21,600 bytes back). Every reader is also fed its file with a byte changed, at every few bytes: an answer each time, and no pixel outside the picture.
|
||||
|
||||
### Checks on the device (2026-10-07, driven over the Debug Console)
|
||||
|
||||
Test pictures were copied to a scratch folder and removed afterwards, with the test screenshot.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Colour bars as PNG, JPEG, BMP and GIF, 240 x 135 and 800 x 600 | The same picture each time, the colours in the right order |
|
||||
| A PNG with a transparent square | The square is the background |
|
||||
| A screenshot taken in the Shell | Shown; at its own size it is the screen, pixel for pixel |
|
||||
| A progressive JPEG | Hex, with "A progressive JPEG can't be shown" |
|
||||
| An animated GIF | Its first picture |
|
||||
| 12 megapixels | Arrives from the top down in 6.9 s; the size is noted when it is whole |
|
||||
| Enter, then the arrows | Its own size from the middle, then half a screen at a time |
|
||||
| Back in the middle of a decoding | The folder's listing at once |
|
||||
| Tab to hex and back, three times; the help panel, then closed | The picture again each time |
|
||||
|
||||
**Not checked:** a photograph from a real camera (the test JPEGs were made by Pillow); a Toast over a picture; a PNG with IRC connected, when there may not be the memory; the card pulled while decoding; the real keyboard.
|
||||
|
||||
### What went wrong while building it
|
||||
|
||||
**The watchdog.** The first version waited for the decoder. The main loop is watched: five seconds without a pass and the device restarts, which is what it did on the 12-megapixel test. The crash report named the decoder's line. The decoding moved to the background, which also made the picture appear as it comes.
|
||||
|
||||
**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.
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -21,7 +21,7 @@ The home page was designed on a canvas in a Claude chat (a dark and a light them
|
||||
| Q175 | The site lives in this repository, in `site/`, so the documentation is built from `docs/`, `CONTEXT.md` and the README instead of being copied. |
|
||||
| Q176 | **Zola,** like the blog. The design becomes a template, its tokens CSS custom properties. Dark and light follow the visitor's setting, with a visible switch. No JavaScript except the flasher's. |
|
||||
| Q177 | Domain: **roro9stack.net.** The blog stays at experiments.twis.la. |
|
||||
| Q178 | **Publishing is the blog's way:** the web server pulls `main` and runs `zola build`; that part is the maintainer's. Changes reach `main` through pull requests as everywhere. **CI is split:** a dedicated `site` job builds the site (`zola build`) when `site/`, `docs/`, `README.md` or `CONTEXT.md` change, and the firmware tests and builds skip a change that touches nothing else. A change that touches both runs both. |
|
||||
| Q178 | **Publishing is the blog's way:** the web server pulls `main` and runs `zola build`; that part is the maintainer's. *(Since issue #79, CI asks the server to do it: see "Published by CI" below.)* Changes reach `main` through pull requests as everywhere. **CI is split:** a dedicated `site` job builds the site (`zola build`) when `site/`, `docs/`, `README.md` or `CONTEXT.md` change, and the firmware tests and builds skip a change that touches nothing else. A change that touches both runs both. |
|
||||
| Q179 | Phases, each its own pull request: **1.** the CI split, the home page, an Install page with the browser flasher, downloads and the changelog. **2.** a user guide page per App. **3.** how-tos and the FAQ. **4.** developer docs generated from the repository. |
|
||||
| Q180 | **A browser flasher** (ESP Web Tools), **without copying the firmware.** Caddy, in front of Gitea, adds `Access-Control-Allow-Origin: https://roro9stack.net` (and `Vary: Origin`) to GET and HEAD on `/twisla/roro9stack/releases/download/*` and `/api/v1/repos/twisla/roro9stack/releases*`: both are public already. The Install page asks the API for the latest release in the browser, finds the asset ending `-factory.bin`, and gives ESP Web Tools a manifest built on the spot, so it offers a new release as soon as it exists, with no rebuild. The library is **vendored** into `site/static/` (Apache-2.0), not loaded from a CDN. The file's SHA-256 is shown on the page. Chrome or Edge on a desktop only; other browsers, and visitors without JavaScript, get the `esptool` steps on the same page. |
|
||||
| Q181 | Docs for the latest version only. The changelog is the Gitea releases, read at build time. |
|
||||
@@ -125,3 +125,60 @@ Not one of the planned phases: the blog's seven roro9stack posts, imported into
|
||||
- **Left out on purpose:** the M0 and M1 milestone documents and `CONTEXT.md` (the glossary) describe Wi-Fi monitoring, which the site does not publish. They stay in the repository.
|
||||
- **The Debug Console pages were written against the source and the live console:** the protocol (the token line, the banner, the 4 KB backlog, one client, 8 queued commands, 240-byte lines, `denied` after a second) and the replies shown were checked on a Debug Build, v0.11.0-3, over Wi-Fi. Not run: `crash abort`, `crash wdt` and Safe Mode, which are described from ADR 0005 and the code.
|
||||
- **Found while writing it:** the README's table lacked the `gnss` commands (rows added); piping commands into `rdbg.py` returns before the replies unless the input stays open (documented, not changed); `update install` on a Debug Build needs `force` (documented).
|
||||
|
||||
## Published by CI (issue #79, design round 2026-10-07)
|
||||
|
||||
Q178 left publishing to the maintainer: a merge, then a command typed on the web server, each time.
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q214 | **A plain ed25519 key with a forced command**, not a certificate: one line in the web server user's `authorized_keys`, `restrict,command="/full/path/to/the/refresh"`. `restrict` takes away the terminal and every forwarding. A certificate could carry the same and an expiry date, at the price of a CA to keep and a key to sign again each time: too much for one key and one command. |
|
||||
| Q215 | **The CI sends no command.** The server runs the forced one whatever is asked for, so there is nothing to keep secret about it and nothing a leaked key could choose. The full path is written once, on the server (a command over SSH doesn't get the user's login `PATH`). |
|
||||
| Q216 | Four secrets: `SITE_DEPLOY_KEY`, `SITE_DEPLOY_HOST` (or `host:port`), `SITE_DEPLOY_USER`, and **`SITE_DEPLOY_KNOWN_HOSTS`**, the server's host key: the job connects to that server or to nothing. None of them is in the repository, which is public. |
|
||||
| Q217 | `from="<the runner's address>"` on the same line: the key works from the runner only. |
|
||||
| Q218 | **The last step of the Site workflow**, after the build and the checks, on a push to `main` only. A pull request never reaches it, and the secrets are given to that step alone. |
|
||||
| Q219 | **After a release too.** The Install page asks Gitea for the latest release when it is opened, but the home page and Downloads read it when the site is built: so the release workflow refreshes the site once the release is published. |
|
||||
| Q220 | A refresh that fails makes the run red, with what the server's script printed: it has to exit with an error when it fails. |
|
||||
| Q221 | Two refreshes at once are the server script's to refuse or queue (`flock`). |
|
||||
| Q222 | The key is a file only while the step runs, in the job's container, as the signing key is. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`scripts/site_refresh.sh`** is what both workflows run: it writes the key and the host key to a temporary folder, connects with no configuration but its own line (`-F none`, strict host key checking, that one key, no command), and removes them. With none of the four secrets it does nothing and says so (a fork, or a repository without them); with only some it fails.
|
||||
- **`scripts/site_deploy_keygen.sh`** makes the key pair once, in `~/.config/roro9stack/`, and prints the `authorized_keys` line and what goes in each secret. It never prints the private key.
|
||||
- **The server's script** should start like this, for Q220 and Q221:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
exec 9>/tmp/rororefresh.lock
|
||||
flock -w 120 9
|
||||
```
|
||||
|
||||
### Checks (2026-10-07, against an SSH server in a throwaway container)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| The refresh | The forced command runs as the server's user; the script ends with `site refresh: done` |
|
||||
| The same key, asking for `id; cat /etc/passwd` | The refresh runs instead; what was asked for is only handed to it as text |
|
||||
| A terminal | Refused: `PTY allocation request failed` |
|
||||
| `scp` with the key | Nothing is copied |
|
||||
| Another host key in the secret | `Host key verification failed`, the run fails, nothing is sent |
|
||||
| The server's script exits with an error | So does the step |
|
||||
| No secrets at all; only one of the four | Does nothing and says so; fails and says which are needed |
|
||||
|
||||
**Not checked:** the real web server and the runner, which wait for the key to be installed: whether the runner reaches the server's SSH port is the first thing the first run will tell. Port forwarding, which `restrict` switches off, was not tried. `from=` was not tried either.
|
||||
|
||||
## Search (issue #60)
|
||||
|
||||
A search over the documentation: the user guide, the how-tos, the questions and answers, and the developer docs. Not the devlog.
|
||||
|
||||
- **The index is the search page itself** (`/search/`, `templates/search.html`): one list item for each page and for each `##` heading of it, with that part's text, written by Zola from the pages' own content when the site is built. Nothing is fetched and nothing typed leaves the browser, so the Content-Security-Policy needs nothing new, and the web server still only runs `zola build`.
|
||||
- **Without JavaScript** the page is a list of every page and heading of the documentation, each a link.
|
||||
- **With it**, `js/search.js` filters and ranks the items as you type: every word has to be in the part; a word in a heading counts for most, the words side by side for more than scattered, and the user guide, the how-tos and the FAQ come before the developer docs, the milestones last. A result links to its heading, with the text around the match.
|
||||
- **The content pages get no script for it:** the navigation has a link, and the index pages of the guide, the how-tos and the developer docs have a box that is a plain form to `/search/?q=`.
|
||||
- **Size:** about 245 items, about 310 KB of HTML, under 100 KB compressed, loaded only by who searches.
|
||||
|
||||
**Checked** in Chromium with the production Content-Security-Policy on every response, no violation: "probation" (the guide's "Probation and Rollback" first), "safe mode", "rm -r", "how big can a note" (the FAQ's question first), a word that isn't there; typing, following a result to its heading, the box on the guide's index, 390 px wide with no sideways scroll, and JavaScript off. `check_site.py` follows every link of the page, so an index entry can't point at a heading that doesn't exist.
|
||||
|
||||
**Not checked:** other browsers, and a screen reader.
|
||||
|
||||
@@ -39,6 +39,17 @@ class App {
|
||||
|
||||
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
|
||||
// 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
|
||||
// that something drawn over it has gone: everything has to be drawn again.
|
||||
virtual bool retainsContent() const { return false; }
|
||||
virtual void contentLost() {}
|
||||
|
||||
void requestRedraw() { redraw_ = true; }
|
||||
|
||||
bool consumeRedraw() {
|
||||
|
||||
@@ -27,6 +27,7 @@ inline constexpr KeyHelp kEverywhere[] = {
|
||||
{"Fn `", "home, the Launcher"},
|
||||
{"; . , /", "arrows (Fn+ while typing)"},
|
||||
{"Fn h ?", "these keys (? not typing)"},
|
||||
{"Fn p", "a screenshot, on the card"},
|
||||
};
|
||||
|
||||
// dialog: A question
|
||||
@@ -222,9 +223,15 @@ inline constexpr KeyHelp kStorage[] = {
|
||||
{"i", "details: size, date, type"},
|
||||
{"s", "sort: name, date, size"},
|
||||
{"m", "Maintenance: clean-up, erase"},
|
||||
{"w", "share with a browser"},
|
||||
{"`", "the folder above"},
|
||||
};
|
||||
|
||||
// storage-share: Storage, sharing with a browser
|
||||
inline constexpr KeyHelp kStorageShare[] = {
|
||||
{"`", "stop sharing"},
|
||||
};
|
||||
|
||||
// storage-details: Storage, an item's details
|
||||
inline constexpr KeyHelp kStorageDetails[] = {
|
||||
{"; .", "scroll"},
|
||||
@@ -255,7 +262,7 @@ inline constexpr KeyHelp kViewerText[] = {
|
||||
{"; .", "a line up, down"},
|
||||
{", /", "a page up, down"},
|
||||
{"t b", "the top, the end"},
|
||||
{"e", "edit it (up to 16 KB)"},
|
||||
{"e", "edit it"},
|
||||
{"Tab", "the file as hex, or back"},
|
||||
};
|
||||
|
||||
@@ -292,6 +299,14 @@ inline constexpr KeyHelp kViewerOta[] = {
|
||||
{"Tab", "the file as hex"},
|
||||
};
|
||||
|
||||
// viewer-image: A picture
|
||||
inline constexpr KeyHelp kViewerImage[] = {
|
||||
{"Enter", "its own size, or all of it"},
|
||||
{"; . , /", "move around it"},
|
||||
{"i", "its size"},
|
||||
{"Tab", "the file as hex"},
|
||||
};
|
||||
|
||||
// notes: Notes, the list
|
||||
inline constexpr KeyHelp kNotes[] = {
|
||||
{"; .", "up, down"},
|
||||
@@ -310,6 +325,7 @@ inline constexpr KeyHelp kNotesEditor[] = {
|
||||
{"Tab", "two spaces"},
|
||||
{"Fn ; . , /", "move the cursor"},
|
||||
{"Alt Fn ; .", "a page up, down"},
|
||||
{"Ctrl Fn ; .", "start, end of the note"},
|
||||
{"Ctrl a e", "start, end of the line"},
|
||||
{"opt ' e", "an accent: \xC3\xA9"},
|
||||
{"`", "done: it saves by itself"},
|
||||
|
||||
@@ -17,6 +17,7 @@ enum class Key : uint8_t {
|
||||
Tab,
|
||||
Delete,
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
#include "image_file.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <new>
|
||||
|
||||
#include "file_names.h"
|
||||
#include "png_rgb332.h"
|
||||
|
||||
namespace roro::files {
|
||||
|
||||
namespace {
|
||||
uint32_t le16(const uint8_t* p) { return p[0] | (p[1] << 8); }
|
||||
uint32_t le32(const uint8_t* p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (static_cast<uint32_t>(p[3]) << 24); }
|
||||
uint32_t be16(const uint8_t* p) { return (p[0] << 8) | p[1]; }
|
||||
uint32_t be32(const uint8_t* p) { return (static_cast<uint32_t>(p[0]) << 24) | (p[1] << 16) | (p[2] << 8) | p[3]; }
|
||||
|
||||
constexpr int kMaxSide = 16384; // more than that isn't a picture for this screen
|
||||
const uint8_t kPngSignature[] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
|
||||
|
||||
// A file read from its start on, a small block at a time.
|
||||
class Stream {
|
||||
public:
|
||||
Stream(const ImageRead& read, uint32_t size) : read_(read), size_(size) {}
|
||||
int get() {
|
||||
if (at_ >= have_) {
|
||||
if (next_ >= size_) return -1;
|
||||
have_ = read_(next_, buf_, std::min<size_t>(sizeof buf_, size_ - next_));
|
||||
at_ = 0;
|
||||
next_ += static_cast<uint32_t>(have_);
|
||||
if (!have_) return -1;
|
||||
}
|
||||
return buf_[at_++];
|
||||
}
|
||||
bool take(uint8_t* into, size_t len) {
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
int c = get();
|
||||
if (c < 0) return false;
|
||||
into[i] = static_cast<uint8_t>(c);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool skip(size_t len) {
|
||||
size_t buffered = std::min(len, have_ - at_);
|
||||
at_ += buffered;
|
||||
len -= buffered;
|
||||
if (len > size_ - next_) return false;
|
||||
next_ += static_cast<uint32_t>(len);
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
const ImageRead& read_;
|
||||
uint32_t size_, next_ = 0;
|
||||
uint8_t buf_[256];
|
||||
size_t have_ = 0, at_ = 0;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
ImageKind imageKindOfName(const std::string& name) {
|
||||
std::string ext = extensionOf(name);
|
||||
if (ext == "png") return ImageKind::Png;
|
||||
if (ext == "jpg" || ext == "jpeg") return ImageKind::Jpeg;
|
||||
if (ext == "bmp") return ImageKind::Bmp;
|
||||
if (ext == "gif") return ImageKind::Gif;
|
||||
return ImageKind::None;
|
||||
}
|
||||
|
||||
ImageKind imageKindOfBytes(const uint8_t* head, size_t len) {
|
||||
if (len >= 8 && std::memcmp(head, kPngSignature, 8) == 0) return ImageKind::Png;
|
||||
if (len >= 3 && head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF) return ImageKind::Jpeg;
|
||||
if (len >= 6 && (std::memcmp(head, "GIF87a", 6) == 0 || std::memcmp(head, "GIF89a", 6) == 0)) return ImageKind::Gif;
|
||||
if (len >= 2 && head[0] == 'B' && head[1] == 'M') return ImageKind::Bmp;
|
||||
return ImageKind::None;
|
||||
}
|
||||
|
||||
const char* imageKindName(ImageKind kind) {
|
||||
switch (kind) {
|
||||
case ImageKind::Png: return "PNG";
|
||||
case ImageKind::Jpeg: return "JPEG";
|
||||
case ImageKind::Bmp: return "BMP";
|
||||
case ImageKind::Gif: return "GIF";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
std::string imageInfo(const ImageRead& read, uint32_t size, ImageInfo& out) {
|
||||
uint8_t head[32];
|
||||
size_t n = read(0, head, std::min<size_t>(sizeof head, size));
|
||||
out.kind = imageKindOfBytes(head, n);
|
||||
long w = 0, h = 0;
|
||||
switch (out.kind) {
|
||||
case ImageKind::Png:
|
||||
if (n < 24 || std::memcmp(head + 12, "IHDR", 4) != 0) return "This PNG is damaged";
|
||||
w = static_cast<long>(be32(head + 16));
|
||||
h = static_cast<long>(be32(head + 20));
|
||||
break;
|
||||
case ImageKind::Gif:
|
||||
if (n < 10) return "This GIF is damaged";
|
||||
w = static_cast<long>(le16(head + 6));
|
||||
h = static_cast<long>(le16(head + 8));
|
||||
break;
|
||||
case ImageKind::Bmp: {
|
||||
if (n < 26) return "This BMP is damaged";
|
||||
uint32_t dib = le32(head + 14);
|
||||
if (dib < 40) return "This kind of BMP can't be shown";
|
||||
w = static_cast<int32_t>(le32(head + 18));
|
||||
h = static_cast<int32_t>(le32(head + 22));
|
||||
if (h < 0) h = -h; // top row first
|
||||
break;
|
||||
}
|
||||
case ImageKind::Jpeg: {
|
||||
// Marker after marker until the one that carries the size.
|
||||
uint32_t at = 2;
|
||||
for (int guard = 0; guard < 4000; guard++) {
|
||||
uint8_t m[9];
|
||||
if (read(at, m, 4) != 4 || m[0] != 0xFF) return "This JPEG is damaged";
|
||||
uint8_t marker = m[1];
|
||||
if (marker == 0xFF) { // padding
|
||||
at++;
|
||||
continue;
|
||||
}
|
||||
if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD8)) { // no length
|
||||
at += 2;
|
||||
continue;
|
||||
}
|
||||
if (marker == 0xD9 || marker == 0xDA) return "This JPEG is damaged"; // the picture, and no size yet
|
||||
bool frame = marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC;
|
||||
if (frame) {
|
||||
if (read(at, m, 9) != 9) return "This JPEG is damaged";
|
||||
h = static_cast<long>(be16(m + 5));
|
||||
w = static_cast<long>(be16(m + 7));
|
||||
if (marker == 0xC2) return "A progressive JPEG can't be shown";
|
||||
if (marker != 0xC0 && marker != 0xC1) return "This kind of JPEG can't be shown";
|
||||
break;
|
||||
}
|
||||
at += 2 + be16(m + 2);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: return "Not a picture this can show";
|
||||
}
|
||||
if (w <= 0 || h <= 0) return "This picture is damaged";
|
||||
if (w > kMaxSide || h > kMaxSide) return "Too big: 16,384 pixels a side at most";
|
||||
out.width = static_cast<int>(w);
|
||||
out.height = static_cast<int>(h);
|
||||
return "";
|
||||
}
|
||||
|
||||
uint32_t screenshotPixelsAt(const ImageRead& read, uint32_t size, int width, int height) {
|
||||
if (width <= 0 || height <= 0 || size != png::Rgb332Writer::fileSize(width, height)) return 0;
|
||||
// Signature, IHDR, then a palette of 256 colours, then the one IDAT: a zlib header and a
|
||||
// single stored block.
|
||||
uint8_t ihdr[5], plte[8], idat[15];
|
||||
constexpr uint32_t kPlteAt = 8 + 25, kIdatAt = kPlteAt + 12 + 768;
|
||||
if (read(24, ihdr, 5) != 5 || ihdr[0] != 8 || ihdr[1] != 3 || ihdr[4] != 0) return 0;
|
||||
if (read(kPlteAt, plte, 8) != 8 || be32(plte) != 768 || std::memcmp(plte + 4, "PLTE", 4) != 0) return 0;
|
||||
if (read(kIdatAt, idat, 15) != 15 || std::memcmp(idat + 4, "IDAT", 4) != 0) return 0;
|
||||
uint32_t raw = static_cast<uint32_t>(width + 1) * static_cast<uint32_t>(height);
|
||||
if (idat[8] != 0x78 || idat[10] != 0x01 || le16(idat + 11) != raw || le16(idat + 13) != (raw ^ 0xFFFF)) return 0;
|
||||
return kIdatAt + 15 + 1; // past the first row's filter byte
|
||||
}
|
||||
|
||||
uint8_t rgb332Dithered(uint8_t r, uint8_t g, uint8_t b, int x, int y) {
|
||||
static const uint8_t kBayer[16] = {0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5};
|
||||
int threshold = kBayer[((y & 3) << 2) | (x & 3)] * 16 + 8; // 8 to 248
|
||||
auto level = [threshold](int v, int top) {
|
||||
int nearest = (v * top + 127) / 255;
|
||||
if (nearest * 255 / top == v) return nearest; // a colour the screen has
|
||||
int low = v * top / 255, rest = v * top - low * 255;
|
||||
return rest > threshold ? low + 1 : low;
|
||||
};
|
||||
return static_cast<uint8_t>((level(r, 7) << 5) | (level(g, 7) << 2) | level(b, 3));
|
||||
}
|
||||
|
||||
bool ImageMap::at(int sx, int sy, int& tx, int& ty) const {
|
||||
if (sx < 0 || sy < 0) return false;
|
||||
if (scale >= 65536) {
|
||||
tx = sx + offX;
|
||||
ty = sy + offY;
|
||||
} else {
|
||||
uint64_t fx = static_cast<uint64_t>(sx) * scale, fy = static_cast<uint64_t>(sy) * scale;
|
||||
if ((fx & 0xFFFF) >= scale || (fy & 0xFFFF) >= scale) return false;
|
||||
tx = static_cast<int>(fx >> 16) + offX;
|
||||
ty = static_cast<int>(fy >> 16) + offY;
|
||||
}
|
||||
if (tx < 0 || ty < 0 || tx >= viewW || ty >= viewH) return false;
|
||||
tx += viewX;
|
||||
ty += viewY;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ImageMap::rowUsed(int sy) const {
|
||||
if (sy < 0) return false;
|
||||
int ty;
|
||||
if (scale >= 65536) {
|
||||
ty = sy + offY;
|
||||
} else {
|
||||
uint64_t fy = static_cast<uint64_t>(sy) * scale;
|
||||
if ((fy & 0xFFFF) >= scale) return false;
|
||||
ty = static_cast<int>(fy >> 16) + offY;
|
||||
}
|
||||
return ty >= 0 && ty < viewH;
|
||||
}
|
||||
|
||||
bool ImageMap::below(int sy) const {
|
||||
if (sy < 0) return false;
|
||||
int ty = scale >= 65536 ? sy + offY : static_cast<int>((static_cast<uint64_t>(sy) * scale) >> 16) + offY;
|
||||
return ty >= viewH;
|
||||
}
|
||||
|
||||
ImageFrame::ImageFrame(int width, int height, int viewX, int viewY, int viewW, int viewH)
|
||||
: w_(std::max(1, width)), h_(std::max(1, height)), vx_(viewX), vy_(viewY), vw_(std::max(1, viewW)), vh_(std::max(1, viewH)) {}
|
||||
|
||||
uint32_t ImageFrame::fitScale() const {
|
||||
uint64_t sx = (static_cast<uint64_t>(vw_) << 16) / static_cast<uint64_t>(w_), sy = (static_cast<uint64_t>(vh_) << 16) / static_cast<uint64_t>(h_);
|
||||
return static_cast<uint32_t>(std::min<uint64_t>(65536, std::max<uint64_t>(1, std::min(sx, sy))));
|
||||
}
|
||||
|
||||
void ImageFrame::toggle() {
|
||||
if (!bigger()) return;
|
||||
actual_ = !actual_;
|
||||
if (actual_) { // the middle of it first
|
||||
panX_ = std::max(0, (w_ - vw_) / 2);
|
||||
panY_ = std::max(0, (h_ - vh_) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
bool ImageFrame::pan(int dx, int dy) {
|
||||
if (!actual_) return false;
|
||||
int x = std::clamp(panX_ + dx * (vw_ / 2), 0, std::max(0, w_ - vw_));
|
||||
int y = std::clamp(panY_ + dy * (vh_ / 2), 0, std::max(0, h_ - vh_));
|
||||
bool moved = x != panX_ || y != panY_;
|
||||
panX_ = x;
|
||||
panY_ = y;
|
||||
return moved;
|
||||
}
|
||||
|
||||
int ImageFrame::percent() const { return actual_ ? 100 : static_cast<int>((static_cast<uint64_t>(fitScale()) * 100 + 32768) >> 16); }
|
||||
|
||||
int ImageFrame::jpegShrink() const {
|
||||
if (actual_) return 0;
|
||||
uint32_t scale = fitScale();
|
||||
int shrink = 0;
|
||||
while (shrink < 3 && (static_cast<uint64_t>(scale) << (shrink + 1)) <= 65536) shrink++;
|
||||
return shrink;
|
||||
}
|
||||
|
||||
ImageMap ImageFrame::map(int shrink) const {
|
||||
ImageMap m;
|
||||
m.viewX = vx_;
|
||||
m.viewY = vy_;
|
||||
m.viewW = vw_;
|
||||
m.viewH = vh_;
|
||||
if (actual_) {
|
||||
m.scale = 65536;
|
||||
m.offX = w_ <= vw_ ? (vw_ - w_) / 2 : -panX_;
|
||||
m.offY = h_ <= vh_ ? (vh_ - h_) / 2 : -panY_;
|
||||
return m;
|
||||
}
|
||||
uint32_t scale = fitScale();
|
||||
int tw = std::max<int>(1, static_cast<int>((static_cast<uint64_t>(w_) * scale) >> 16));
|
||||
int th = std::max<int>(1, static_cast<int>((static_cast<uint64_t>(h_) * scale) >> 16));
|
||||
m.offX = (vw_ - tw) / 2;
|
||||
m.offY = (vh_ - th) / 2;
|
||||
m.scale = static_cast<uint32_t>(std::min<uint64_t>(65536, static_cast<uint64_t>(scale) << shrink));
|
||||
return m;
|
||||
}
|
||||
|
||||
std::string readBmp(const ImageRead& read, uint32_t size, const ImagePixels& pixels, const std::function<bool(int y)>& rowNeeded) {
|
||||
uint8_t head[54];
|
||||
if (read(0, head, sizeof head) != sizeof head || head[0] != 'B' || head[1] != 'M') return "This BMP is damaged";
|
||||
uint32_t dataAt = le32(head + 10), dib = le32(head + 14), compression = le32(head + 30), colours = le32(head + 46);
|
||||
int32_t w = static_cast<int32_t>(le32(head + 18)), h = static_cast<int32_t>(le32(head + 22));
|
||||
uint32_t bits = le16(head + 28);
|
||||
bool topDown = h < 0;
|
||||
if (topDown) h = -h;
|
||||
if (dib < 40 || w <= 0 || h <= 0 || w > kMaxSide || h > kMaxSide) return "This kind of BMP can't be shown";
|
||||
if ((bits != 8 && bits != 24 && bits != 32) || (compression != 0 && !(compression == 3 && bits == 32))) return "This kind of BMP can't be shown";
|
||||
std::unique_ptr<uint8_t[]> palette;
|
||||
if (bits == 8) {
|
||||
if (!colours || colours > 256) colours = 256;
|
||||
palette.reset(new (std::nothrow) uint8_t[1024]());
|
||||
if (!palette) return "Not enough memory";
|
||||
if (read(14 + dib, palette.get(), colours * 4) != colours * 4) return "This BMP is damaged";
|
||||
}
|
||||
uint32_t bytes = bits / 8, rowSize = (static_cast<uint32_t>(w) * bytes + 3) & ~3u;
|
||||
if (static_cast<uint64_t>(dataAt) + static_cast<uint64_t>(rowSize) * static_cast<uint32_t>(h) > size) return "This BMP is cut short";
|
||||
// A row in one read when it fits, a piece of it at a time otherwise.
|
||||
constexpr int kOut = 64; // pixels handed on at once
|
||||
constexpr uint32_t kRowBuffer = 4096; // bytes
|
||||
uint32_t rowBytes = static_cast<uint32_t>(w) * bytes, bufSize = std::min(rowBytes, kRowBuffer);
|
||||
bufSize -= bufSize % bytes;
|
||||
std::unique_ptr<uint8_t[]> in(new (std::nothrow) uint8_t[bufSize]);
|
||||
if (!in) return "Not enough memory";
|
||||
uint8_t out[kOut * 3];
|
||||
// In the order the file has them, which is usually the last row first: going back through a
|
||||
// file on the card costs far more than going on (measured: a second for 135 rows).
|
||||
for (int stored = 0; stored < h; stored++) {
|
||||
int y = topDown ? stored : h - 1 - stored;
|
||||
if (rowNeeded && !rowNeeded(y)) continue;
|
||||
uint32_t rowAt = dataAt + rowSize * static_cast<uint32_t>(stored);
|
||||
for (uint32_t done = 0; done < rowBytes; done += bufSize) {
|
||||
size_t want = std::min(bufSize, rowBytes - done);
|
||||
if (read(rowAt + done, in.get(), want) != want) return "The card refused to read it";
|
||||
int first = static_cast<int>(done / bytes), count = static_cast<int>(want / bytes);
|
||||
for (int at = 0; at < count; at += kOut) {
|
||||
int n = std::min(kOut, count - at);
|
||||
for (int i = 0; i < n; i++) {
|
||||
const uint8_t* p = bits == 8 ? palette.get() + in[at + i] * 4 : in.get() + static_cast<size_t>(at + i) * bytes;
|
||||
out[i * 3] = p[2]; // stored blue, green, red
|
||||
out[i * 3 + 1] = p[1];
|
||||
out[i * 3 + 2] = p[0];
|
||||
}
|
||||
pixels(first + at, y, n, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
namespace {
|
||||
struct GifWork {
|
||||
uint8_t palette[768];
|
||||
uint16_t prefix[4096];
|
||||
uint8_t suffix[4096], stack[4096];
|
||||
};
|
||||
} // namespace
|
||||
|
||||
std::string readGif(const ImageRead& read, uint32_t size, const ImagePixels& pixels) {
|
||||
Stream in(read, size);
|
||||
uint8_t head[13];
|
||||
if (!in.take(head, 13) || imageKindOfBytes(head, 6) != ImageKind::Gif) return "This GIF is damaged";
|
||||
int screenW = static_cast<int>(le16(head + 6)), screenH = static_cast<int>(le16(head + 8));
|
||||
std::unique_ptr<GifWork> work(new (std::nothrow) GifWork);
|
||||
if (!work) return "Not enough memory to show a GIF";
|
||||
std::memset(work->palette, 0, sizeof work->palette);
|
||||
if (head[10] & 0x80 && !in.take(work->palette, 3u << ((head[10] & 7) + 1))) return "This GIF is damaged";
|
||||
int transparent = -1;
|
||||
for (int guard = 0; guard < 100000; guard++) {
|
||||
int kind = in.get();
|
||||
if (kind == 0x21) { // an extension: only the one before a picture matters, for its transparent colour
|
||||
int label = in.get();
|
||||
for (bool first = true;; first = false) {
|
||||
int len = in.get();
|
||||
if (len < 0) return "This GIF is damaged";
|
||||
if (len == 0) break;
|
||||
uint8_t block[255];
|
||||
if (!in.take(block, static_cast<size_t>(len))) return "This GIF is damaged";
|
||||
if (label == 0xF9 && first && len >= 4) transparent = (block[0] & 1) ? block[3] : -1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (kind != 0x2C) return kind == 0x3B ? "This GIF has no picture" : "This GIF is damaged";
|
||||
break;
|
||||
}
|
||||
uint8_t desc[9];
|
||||
if (!in.take(desc, 9)) return "This GIF is damaged";
|
||||
int left = static_cast<int>(le16(desc)), top = static_cast<int>(le16(desc + 2));
|
||||
int fw = static_cast<int>(le16(desc + 4)), fh = static_cast<int>(le16(desc + 6));
|
||||
bool interlaced = desc[8] & 0x40;
|
||||
if (desc[8] & 0x80 && !in.take(work->palette, 3u << ((desc[8] & 7) + 1))) return "This GIF is damaged";
|
||||
int minBits = in.get();
|
||||
if (fw <= 0 || fh <= 0 || minBits < 2 || minBits > 8) return "This GIF is damaged";
|
||||
|
||||
// The pixels come out in the order they are stored; an interlaced picture stores every eighth
|
||||
// row first, then the rows between, in four passes.
|
||||
static const int kStart[4] = {0, 4, 2, 1}, kStep[4] = {8, 8, 4, 2};
|
||||
int px = 0, row = 0, pass = 0, rowsDone = 0;
|
||||
uint8_t run[64 * 3];
|
||||
int runLen = 0, runX = 0;
|
||||
auto flush = [&]() {
|
||||
int y = top + row;
|
||||
if (runLen && y >= 0 && y < screenH) pixels(left + runX, y, runLen, run);
|
||||
runLen = 0;
|
||||
};
|
||||
auto put = [&](uint8_t index) {
|
||||
if (rowsDone >= fh) return;
|
||||
int x = left + px;
|
||||
if (index == transparent || x < 0 || x >= screenW) {
|
||||
flush();
|
||||
} else {
|
||||
if (!runLen) runX = px;
|
||||
std::memcpy(run + runLen * 3, work->palette + index * 3, 3);
|
||||
if (++runLen == 64) flush();
|
||||
}
|
||||
if (++px < fw) return;
|
||||
flush();
|
||||
px = 0;
|
||||
rowsDone++;
|
||||
if (!interlaced) {
|
||||
row++;
|
||||
return;
|
||||
}
|
||||
row += kStep[pass];
|
||||
while (row >= fh && pass < 3) row = kStart[++pass];
|
||||
};
|
||||
|
||||
const int clear = 1 << minBits, stop = clear + 1;
|
||||
int bits = minBits + 1, next = clear + 2, prev = -1, first = 0;
|
||||
uint32_t hold = 0;
|
||||
int held = 0, blockLeft = 0;
|
||||
bool ended = false;
|
||||
for (int i = 0; i < clear; i++) work->suffix[i] = static_cast<uint8_t>(i);
|
||||
while (rowsDone < fh && !ended) {
|
||||
while (held < bits) {
|
||||
if (!blockLeft) {
|
||||
blockLeft = in.get();
|
||||
if (blockLeft <= 0) {
|
||||
ended = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
int c = in.get();
|
||||
if (c < 0) return "This GIF is cut short";
|
||||
blockLeft--;
|
||||
hold |= static_cast<uint32_t>(c) << held;
|
||||
held += 8;
|
||||
}
|
||||
if (ended) break;
|
||||
int code = static_cast<int>(hold & ((1u << bits) - 1));
|
||||
hold >>= bits;
|
||||
held -= bits;
|
||||
if (code == clear) {
|
||||
bits = minBits + 1;
|
||||
next = clear + 2;
|
||||
prev = -1;
|
||||
continue;
|
||||
}
|
||||
if (code == stop) break;
|
||||
if (prev < 0) {
|
||||
if (code >= clear) return "This GIF is damaged";
|
||||
put(static_cast<uint8_t>(code));
|
||||
first = prev = code;
|
||||
continue;
|
||||
}
|
||||
if (code > next) return "This GIF is damaged";
|
||||
int sp = 0, walk = code;
|
||||
if (code == next) { // the string being defined: the one before, and its own first pixel again
|
||||
work->stack[sp++] = static_cast<uint8_t>(first);
|
||||
walk = prev;
|
||||
}
|
||||
while (walk >= clear && sp < 4095) {
|
||||
work->stack[sp++] = work->suffix[walk];
|
||||
walk = work->prefix[walk];
|
||||
}
|
||||
if (walk >= clear) return "This GIF is damaged";
|
||||
first = walk;
|
||||
work->stack[sp++] = static_cast<uint8_t>(walk);
|
||||
if (next < 4096) {
|
||||
work->prefix[next] = static_cast<uint16_t>(prev);
|
||||
work->suffix[next] = static_cast<uint8_t>(first);
|
||||
next++;
|
||||
if (next == (1 << bits) && bits < 12) bits++;
|
||||
}
|
||||
prev = code;
|
||||
while (sp) put(work->stack[--sp]);
|
||||
}
|
||||
flush();
|
||||
return rowsDone ? "" : "This GIF is damaged";
|
||||
}
|
||||
|
||||
} // namespace roro::files
|
||||
@@ -0,0 +1,86 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
// Pictures for the Storage App (issue #45, F1 Q233-Q242): what a file is and how big, where each
|
||||
// of its pixels goes on the screen, and the readers for BMP and the first frame of a GIF. PNG is in
|
||||
// png_reader.h; JPEG is decoded on the device by the display library's decoder.
|
||||
// Nothing here holds a picture: every reader hands its pixels on as it gets them.
|
||||
namespace roro::files {
|
||||
|
||||
enum class ImageKind : uint8_t { None, Png, Jpeg, Bmp, Gif };
|
||||
|
||||
// Reads up to `len` bytes at `offset`; returns how many it got.
|
||||
using ImageRead = std::function<size_t(uint32_t offset, uint8_t* into, size_t len)>;
|
||||
// `count` pixels of row `y` from column `x` on, three bytes each: red, green, blue.
|
||||
using ImagePixels = std::function<void(int x, int y, int count, const uint8_t* rgb)>;
|
||||
|
||||
ImageKind imageKindOfName(const std::string& name); // by its extension
|
||||
ImageKind imageKindOfBytes(const uint8_t* head, size_t len); // by its first bytes (8 are enough)
|
||||
const char* imageKindName(ImageKind kind);
|
||||
|
||||
struct ImageInfo {
|
||||
ImageKind kind = ImageKind::None;
|
||||
int width = 0, height = 0;
|
||||
};
|
||||
// "" and `out` filled, or why the file can't be shown.
|
||||
std::string imageInfo(const ImageRead& read, uint32_t size, ImageInfo& out);
|
||||
|
||||
// A PNG the firmware's `screenshot` wrote (png_rgb332.h): its pixels are not compressed and each
|
||||
// is already a colour of the screen. Where the first row's pixels start, or 0 if it isn't one.
|
||||
// Row y's pixels are at that offset + y * (width + 1).
|
||||
uint32_t screenshotPixelsAt(const ImageRead& read, uint32_t size, int width, int height);
|
||||
|
||||
// A colour as the screen has it (RRRGGGBB), dithered by where it lands: the screen has 8 levels of
|
||||
// red and green and 4 of blue, and a photograph bands without it. A colour the screen has exactly
|
||||
// comes out as itself wherever it lands, so a screenshot isn't touched.
|
||||
uint8_t rgb332Dithered(uint8_t r, uint8_t g, uint8_t b, int x, int y);
|
||||
|
||||
// From a picture's pixels to the screen's.
|
||||
struct ImageMap {
|
||||
int viewX = 0, viewY = 0, viewW = 0, viewH = 0; // the part of the screen the picture may use
|
||||
int offX = 0, offY = 0; // the picture's corner in it (negative: scrolled)
|
||||
uint32_t scale = 65536; // screen pixels for one of the picture's, 16.16; never over 1
|
||||
|
||||
// Where the picture's pixel lands. False if it's outside the view, or if another pixel is the
|
||||
// one drawn there (shrunk, each screen pixel takes the first of the picture's that falls on it).
|
||||
bool at(int sx, int sy, int& tx, int& ty) const;
|
||||
bool rowUsed(int sy) const; // does any pixel of this row land?
|
||||
bool below(int sy) const; // this row and every one after it land under the view: nothing more to draw
|
||||
};
|
||||
|
||||
// How a picture is looked at: whole, shrunk to fit if it has to be; or at its own size, a
|
||||
// screenful at a time.
|
||||
class ImageFrame {
|
||||
public:
|
||||
ImageFrame() = default;
|
||||
ImageFrame(int width, int height, int viewX, int viewY, int viewW, int viewH);
|
||||
|
||||
bool bigger() const { return w_ > vw_ || h_ > vh_; } // than the view: there is something to zoom
|
||||
bool actual() const { return actual_; }
|
||||
void toggle();
|
||||
bool pan(int dx, int dy); // half a view a step, at its own size only; false: nothing moved
|
||||
int percent() const; // of its own size, as shown
|
||||
int jpegShrink() const; // 0 to 3: the halvings a JPEG decoder may do first, the picture still at least as big as shown
|
||||
// For pixels counted after `shrink` halvings (a JPEG's), or the picture's own.
|
||||
ImageMap map(int shrink = 0) const;
|
||||
|
||||
private:
|
||||
uint32_t fitScale() const;
|
||||
|
||||
int w_ = 0, h_ = 0, vx_ = 0, vy_ = 0, vw_ = 1, vh_ = 1, panX_ = 0, panY_ = 0;
|
||||
bool actual_ = false;
|
||||
};
|
||||
|
||||
// A BMP: 8 bits with a palette, 24 or 32 bits, not compressed. `rowNeeded` lets rows be skipped
|
||||
// without being read. "" or why it can't be shown.
|
||||
std::string readBmp(const ImageRead& read, uint32_t size, const ImagePixels& pixels, const std::function<bool(int y)>& rowNeeded = nullptr);
|
||||
|
||||
// The first picture of a GIF, interlaced or not; transparent pixels are not handed on. It needs
|
||||
// 17 KB while it runs. "" or why it can't be shown.
|
||||
std::string readGif(const ImageRead& read, uint32_t size, const ImagePixels& pixels);
|
||||
|
||||
} // namespace roro::files
|
||||
@@ -0,0 +1,354 @@
|
||||
#include "png_reader.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <new>
|
||||
|
||||
namespace roro::files {
|
||||
|
||||
namespace {
|
||||
uint32_t be32(const uint8_t* p) { return (static_cast<uint32_t>(p[0]) << 24) | (p[1] << 16) | (p[2] << 8) | p[3]; }
|
||||
|
||||
const char* const kDamaged = "This PNG is damaged";
|
||||
const char* const kCut = "This PNG is cut short";
|
||||
const char* const kNoMemory = "Not enough memory for this PNG";
|
||||
|
||||
// A Huffman code as its lengths say: how many codes of each length, and the symbols in order.
|
||||
struct Huffman {
|
||||
uint16_t count[16];
|
||||
uint16_t symbol[288];
|
||||
|
||||
// False if the lengths don't make a code.
|
||||
bool build(const uint8_t* lengths, int n) {
|
||||
std::memset(count, 0, sizeof count);
|
||||
for (int i = 0; i < n; i++) count[lengths[i]]++;
|
||||
int left = 1;
|
||||
for (int len = 1; len < 16; len++) {
|
||||
left = (left << 1) - count[len];
|
||||
if (left < 0) return false;
|
||||
}
|
||||
uint16_t offs[16];
|
||||
offs[1] = 0;
|
||||
for (int len = 1; len < 15; len++) offs[len + 1] = static_cast<uint16_t>(offs[len] + count[len]);
|
||||
for (int i = 0; i < n; i++)
|
||||
if (lengths[i]) symbol[offs[lengths[i]]++] = static_cast<uint16_t>(i);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Everything one decoding holds, but the window and the two rows: on the heap, in one piece.
|
||||
struct Work {
|
||||
// The file, and the IDAT chunks as one stream of bytes.
|
||||
const ImageRead* read = nullptr;
|
||||
uint32_t size = 0, at = 0, chunkLeft = 0;
|
||||
uint8_t in[256];
|
||||
size_t inHave = 0, inAt = 0;
|
||||
bool inEnd = false;
|
||||
// Bits.
|
||||
uint32_t hold = 0;
|
||||
int held = 0;
|
||||
// The picture.
|
||||
int width = 0, height = 0, depth = 0, type = 0, channels = 0, bpp = 0;
|
||||
uint32_t rowBytes = 0;
|
||||
uint8_t palette[768], alpha[256];
|
||||
bool hasAlpha = false;
|
||||
// The window, the rows, and where the decoding is.
|
||||
std::unique_ptr<uint8_t[]> window, rows;
|
||||
uint32_t windowSize = 0, written = 0;
|
||||
uint8_t *cur = nullptr, *prev = nullptr;
|
||||
int64_t pos = -1; // in the row; -1: its filter byte comes next
|
||||
int filter = 0, y = 0;
|
||||
bool stop = false;
|
||||
const char* problem = nullptr;
|
||||
const ImagePixels* pixels = nullptr;
|
||||
const std::function<bool(int)>* rowNeeded = nullptr;
|
||||
const std::function<bool(int)>* enough = nullptr;
|
||||
Huffman lengths, distances;
|
||||
uint8_t codeLengths[320];
|
||||
|
||||
int byte() {
|
||||
if (inAt >= inHave) {
|
||||
while (!chunkLeft && !inEnd) { // the next IDAT, past this one's checksum
|
||||
uint8_t head[12];
|
||||
if (at + 12 > size || (*read)(at, head, 12) != 12) return inEnd = true, -1;
|
||||
at += 4; // the checksum
|
||||
if (std::memcmp(head + 8, "IDAT", 4) != 0) return inEnd = true, -1;
|
||||
chunkLeft = be32(head + 4);
|
||||
at += 8;
|
||||
}
|
||||
if (inEnd) return -1;
|
||||
size_t want = std::min<size_t>(sizeof in, chunkLeft);
|
||||
inHave = (*read)(at, in, want);
|
||||
inAt = 0;
|
||||
if (inHave != want) return inEnd = true, -1;
|
||||
at += static_cast<uint32_t>(want);
|
||||
chunkLeft -= static_cast<uint32_t>(want);
|
||||
}
|
||||
return in[inAt++];
|
||||
}
|
||||
int bits(int n) { // -1: no more
|
||||
while (held < n) {
|
||||
int b = byte();
|
||||
if (b < 0) return -1;
|
||||
hold |= static_cast<uint32_t>(b) << held;
|
||||
held += 8;
|
||||
}
|
||||
int v = static_cast<int>(hold & ((1u << n) - 1));
|
||||
hold >>= n;
|
||||
held -= n;
|
||||
return v;
|
||||
}
|
||||
int decode(const Huffman& h) { // -1: no more, or not a code
|
||||
int code = 0, first = 0, index = 0;
|
||||
for (int len = 1; len < 16; len++) {
|
||||
int b = bits(1);
|
||||
if (b < 0) return -1;
|
||||
code |= b;
|
||||
int n = h.count[len];
|
||||
if (code - n < first) return h.symbol[index + (code - first)];
|
||||
index += n;
|
||||
first += n;
|
||||
first <<= 1;
|
||||
code <<= 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void row();
|
||||
// One byte out of the decompression: into the window, and into the row being rebuilt.
|
||||
void out(uint8_t b) {
|
||||
window[written++ & (windowSize - 1)] = b;
|
||||
if (pos < 0) {
|
||||
if (b > 4) {
|
||||
problem = kDamaged;
|
||||
stop = true;
|
||||
}
|
||||
filter = b;
|
||||
pos = 0;
|
||||
return;
|
||||
}
|
||||
uint32_t i = static_cast<uint32_t>(pos);
|
||||
int a = i >= static_cast<uint32_t>(bpp) ? cur[i - bpp] : 0, up = prev[i], c = i >= static_cast<uint32_t>(bpp) ? prev[i - bpp] : 0, add = 0;
|
||||
switch (filter) {
|
||||
case 1: add = a; break;
|
||||
case 2: add = up; break;
|
||||
case 3: add = (a + up) >> 1; break;
|
||||
case 4: {
|
||||
int p = a + up - c, pa = std::abs(p - a), pb = std::abs(p - up), pc = std::abs(p - c);
|
||||
add = pa <= pb && pa <= pc ? a : pb <= pc ? up : c;
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
cur[i] = static_cast<uint8_t>(b + add);
|
||||
if (static_cast<uint32_t>(++pos) < rowBytes) return;
|
||||
if (!rowNeeded || !*rowNeeded || (*rowNeeded)(y)) row();
|
||||
std::swap(cur, prev);
|
||||
pos = -1;
|
||||
y++;
|
||||
if (y >= height || (enough && *enough && (*enough)(y))) stop = true;
|
||||
}
|
||||
bool inflate();
|
||||
};
|
||||
|
||||
// The row as colours: runs of pixels, broken where one is transparent.
|
||||
void Work::row() {
|
||||
uint8_t run[64 * 3];
|
||||
int n = 0, from = 0;
|
||||
auto flush = [&]() {
|
||||
if (n) (*pixels)(from, y, n, run);
|
||||
n = 0;
|
||||
};
|
||||
int top = (1 << depth) - 1;
|
||||
for (int x = 0; x < width; x++) {
|
||||
uint8_t r, g, b, a = 255;
|
||||
auto sample = [&](int k) -> int { // the k-th value of this pixel, as 8 bits; an index stays an index
|
||||
if (depth == 8) return cur[x * channels + k];
|
||||
if (depth == 16) return cur[(x * channels + k) * 2];
|
||||
int bit = x * depth, v = (cur[bit >> 3] >> (8 - depth - (bit & 7))) & top;
|
||||
return type == 3 ? v : v * 255 / top;
|
||||
};
|
||||
switch (type) {
|
||||
case 0: r = g = b = static_cast<uint8_t>(sample(0)); break;
|
||||
case 2: r = static_cast<uint8_t>(sample(0)), g = static_cast<uint8_t>(sample(1)), b = static_cast<uint8_t>(sample(2)); break;
|
||||
case 3: {
|
||||
int i = sample(0);
|
||||
r = palette[i * 3], g = palette[i * 3 + 1], b = palette[i * 3 + 2];
|
||||
if (hasAlpha) a = alpha[i];
|
||||
break;
|
||||
}
|
||||
case 4: r = g = b = static_cast<uint8_t>(sample(0)), a = static_cast<uint8_t>(sample(1)); break;
|
||||
default: r = static_cast<uint8_t>(sample(0)), g = static_cast<uint8_t>(sample(1)), b = static_cast<uint8_t>(sample(2)), a = static_cast<uint8_t>(sample(3)); break;
|
||||
}
|
||||
if (a < 128) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
if (!n) from = x;
|
||||
run[n * 3] = r, run[n * 3 + 1] = g, run[n * 3 + 2] = b;
|
||||
if (++n == 64) flush();
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
// Deflate (RFC 1951) inside a zlib stream (RFC 1950). False with `problem` set, or true when the
|
||||
// stream ended or enough rows were made.
|
||||
bool Work::inflate() {
|
||||
static const uint16_t kLenBase[29] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258};
|
||||
static const uint8_t kLenExtra[29] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0};
|
||||
static const uint16_t kDistBase[30] = {1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577};
|
||||
static const uint8_t kDistExtra[30] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13};
|
||||
static const uint8_t kOrder[19] = {16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15};
|
||||
auto fail = [this](const char* what) {
|
||||
if (!problem) problem = what;
|
||||
return false;
|
||||
};
|
||||
for (bool last = false; !last && !stop;) {
|
||||
int head = bits(3);
|
||||
if (head < 0) return fail(kCut);
|
||||
last = head & 1;
|
||||
int kind = head >> 1;
|
||||
if (kind == 0) { // stored
|
||||
hold = 0;
|
||||
held = 0;
|
||||
int a = byte(), b = byte(), c = byte(), d = byte();
|
||||
if (d < 0) return fail(kCut);
|
||||
int len = a | (b << 8);
|
||||
if (len != ((c | (d << 8)) ^ 0xFFFF)) return fail(kDamaged);
|
||||
for (int i = 0; i < len && !stop; i++) {
|
||||
int v = byte();
|
||||
if (v < 0) return fail(kCut);
|
||||
out(static_cast<uint8_t>(v));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (kind == 3) return fail(kDamaged);
|
||||
if (kind == 1) { // the code every decoder knows
|
||||
for (int i = 0; i < 288; i++) codeLengths[i] = i < 144 ? 8 : i < 256 ? 9 : i < 280 ? 7 : 8;
|
||||
lengths.build(codeLengths, 288);
|
||||
for (int i = 0; i < 30; i++) codeLengths[i] = 5;
|
||||
distances.build(codeLengths, 30);
|
||||
} else { // a code of the block's own, itself sent coded
|
||||
int nlen = bits(5), ndist = bits(5), ncode = bits(4);
|
||||
if (ncode < 0) return fail(kCut);
|
||||
nlen += 257, ndist += 1, ncode += 4;
|
||||
if (nlen > 286 || ndist > 30) return fail(kDamaged);
|
||||
uint8_t first[19] = {0};
|
||||
for (int i = 0; i < ncode; i++) {
|
||||
int v = bits(3);
|
||||
if (v < 0) return fail(kCut);
|
||||
first[kOrder[i]] = static_cast<uint8_t>(v);
|
||||
}
|
||||
if (!lengths.build(first, 19)) return fail(kDamaged);
|
||||
for (int i = 0; i < nlen + ndist;) {
|
||||
int sym = decode(lengths);
|
||||
if (sym < 0) return fail(kDamaged);
|
||||
if (sym < 16) {
|
||||
codeLengths[i++] = static_cast<uint8_t>(sym);
|
||||
continue;
|
||||
}
|
||||
int repeat, value = 0;
|
||||
if (sym == 16) {
|
||||
if (!i) return fail(kDamaged);
|
||||
value = codeLengths[i - 1];
|
||||
repeat = 3 + bits(2);
|
||||
} else if (sym == 17) {
|
||||
repeat = 3 + bits(3);
|
||||
} else {
|
||||
repeat = 11 + bits(7);
|
||||
}
|
||||
if (i + repeat > nlen + ndist) return fail(kDamaged);
|
||||
while (repeat--) codeLengths[i++] = static_cast<uint8_t>(value);
|
||||
}
|
||||
uint8_t dist[30];
|
||||
std::memcpy(dist, codeLengths + nlen, static_cast<size_t>(ndist));
|
||||
if (!lengths.build(codeLengths, nlen) || !distances.build(dist, ndist)) return fail(kDamaged);
|
||||
}
|
||||
while (!stop) {
|
||||
int sym = decode(lengths);
|
||||
if (sym < 0) return fail(inEnd ? kCut : kDamaged);
|
||||
if (sym < 256) {
|
||||
out(static_cast<uint8_t>(sym));
|
||||
continue;
|
||||
}
|
||||
if (sym == 256) break;
|
||||
sym -= 257;
|
||||
if (sym >= 29) return fail(kDamaged);
|
||||
int extra = bits(kLenExtra[sym]);
|
||||
int dsym = decode(distances);
|
||||
if (extra < 0 || dsym < 0 || dsym >= 30) return fail(inEnd ? kCut : kDamaged);
|
||||
int dextra = bits(kDistExtra[dsym]);
|
||||
if (dextra < 0) return fail(kCut);
|
||||
uint32_t len = static_cast<uint32_t>(kLenBase[sym] + extra), dist = static_cast<uint32_t>(kDistBase[dsym] + dextra);
|
||||
if (dist > written || dist > windowSize) return fail(kDamaged);
|
||||
for (uint32_t i = 0; i < len && !stop; i++) out(window[(written - dist) & (windowSize - 1)]);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string readPng(const ImageRead& read, uint32_t size, const ImagePixels& pixels, const std::function<bool(int y)>& rowNeeded,
|
||||
const std::function<bool(int y)>& enough) {
|
||||
static const uint8_t kSignature[] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
|
||||
uint8_t head[33];
|
||||
if (read(0, head, sizeof head) != sizeof head || std::memcmp(head, kSignature, 8) != 0 || std::memcmp(head + 12, "IHDR", 4) != 0) return kDamaged;
|
||||
std::unique_ptr<Work> w(new (std::nothrow) Work);
|
||||
if (!w) return kNoMemory;
|
||||
w->read = &read;
|
||||
w->size = size;
|
||||
w->pixels = &pixels;
|
||||
w->rowNeeded = &rowNeeded;
|
||||
w->enough = &enough;
|
||||
uint32_t width = be32(head + 16), height = be32(head + 20);
|
||||
w->depth = head[24];
|
||||
w->type = head[25];
|
||||
if (!width || !height || width > 16384 || height > 16384 || head[26] || head[27]) return kDamaged;
|
||||
if (head[28]) return "An interlaced PNG can't be shown";
|
||||
w->width = static_cast<int>(width);
|
||||
w->height = static_cast<int>(height);
|
||||
static const int8_t kChannels[7] = {1, 0, 3, 1, 2, 0, 4};
|
||||
int depth = w->depth, type = w->type;
|
||||
bool depthOk = depth == 8 || (depth == 16 && type != 3) || ((depth == 1 || depth == 2 || depth == 4) && (type == 0 || type == 3));
|
||||
if (type > 6 || !kChannels[type] || !depthOk) return "This kind of PNG can't be shown";
|
||||
w->channels = kChannels[type];
|
||||
w->bpp = std::max(1, w->channels * depth / 8);
|
||||
w->rowBytes = (width * static_cast<uint32_t>(w->channels * depth) + 7) / 8;
|
||||
std::memset(w->palette, 0, sizeof w->palette);
|
||||
std::memset(w->alpha, 255, sizeof w->alpha);
|
||||
|
||||
// The chunks before the picture: the palette and its transparency.
|
||||
uint32_t at = 33;
|
||||
for (int guard = 0; guard < 1000; guard++) {
|
||||
uint8_t c[8];
|
||||
if (at + 8 > size || read(at, c, 8) != 8) return kCut;
|
||||
uint32_t len = be32(c);
|
||||
if (std::memcmp(c + 4, "IDAT", 4) == 0) break;
|
||||
if (std::memcmp(c + 4, "IEND", 4) == 0 || len > size) return kDamaged;
|
||||
if (std::memcmp(c + 4, "PLTE", 4) == 0 && read(at + 8, w->palette, std::min<size_t>(len, 768)) != std::min<size_t>(len, 768)) return kCut;
|
||||
if (std::memcmp(c + 4, "tRNS", 4) == 0 && type == 3) {
|
||||
if (read(at + 8, w->alpha, std::min<size_t>(len, 256)) != std::min<size_t>(len, 256)) return kCut;
|
||||
w->hasAlpha = true;
|
||||
}
|
||||
at += 12 + len;
|
||||
}
|
||||
w->at = at - 4; // as if a chunk's checksum had just been reached: byte() steps over it to the IDAT
|
||||
|
||||
// The zlib header says how far back the data refers: the window is that big and no bigger.
|
||||
int cmf = w->byte(), flg = w->byte();
|
||||
if (flg < 0) return kCut;
|
||||
if ((cmf & 0x0F) != 8 || (cmf >> 4) > 7 || ((cmf << 8) | flg) % 31 || (flg & 0x20)) return kDamaged;
|
||||
w->windowSize = 1u << ((cmf >> 4) + 8);
|
||||
w->window.reset(new (std::nothrow) uint8_t[w->windowSize]);
|
||||
w->rows.reset(new (std::nothrow) uint8_t[static_cast<size_t>(w->rowBytes) * 2]());
|
||||
if (!w->window || !w->rows) return kNoMemory;
|
||||
w->cur = w->rows.get();
|
||||
w->prev = w->rows.get() + w->rowBytes;
|
||||
if (enough && enough(0)) return "";
|
||||
if (!w->inflate()) return w->problem ? w->problem : kDamaged;
|
||||
if (w->problem) return w->problem;
|
||||
return w->stop ? "" : kCut; // the data ended before the last row
|
||||
}
|
||||
|
||||
} // namespace roro::files
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include "image_file.h"
|
||||
|
||||
namespace roro::files {
|
||||
|
||||
// A PNG, decoded a row at a time (issue #45): every colour type and bit depth, not interlaced.
|
||||
// Pixels that are mostly transparent are not handed on. `rowNeeded` lets rows be left out (they
|
||||
// are still decoded: a row is stored as its difference from the one before); `enough` says that
|
||||
// from this row on nothing is wanted, and the decoding stops there.
|
||||
//
|
||||
// Memory while it runs: the window the file's compression refers back into (what its header asks
|
||||
// for, 32 KB at most), two rows of the picture, and about 3 KB. The display library's decoder
|
||||
// wanted 44 KB in one block, which this device often doesn't have.
|
||||
//
|
||||
// "" or why it can't be shown.
|
||||
std::string readPng(const ImageRead& read, uint32_t size, const ImagePixels& pixels, const std::function<bool(int y)>& rowNeeded = nullptr,
|
||||
const std::function<bool(int y)>& enough = nullptr);
|
||||
|
||||
} // namespace roro::files
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -106,6 +106,13 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
|
||||
return;
|
||||
}
|
||||
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 '?':
|
||||
if (!textEntry_ && !keys.fn) { // ? alone, when it wouldn't be typed
|
||||
out.push_back(KeyEvent::of(Key::Help));
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
#include "note_document.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace roro::notes {
|
||||
|
||||
namespace {
|
||||
// The side file: this line, the note's size and two checksums of it (its first and last
|
||||
// kilobyte), then, in any order, text that left a window and snapshots of the list of pieces.
|
||||
// snapshot: "RSNP" cursor count { src at len }... crc32 length "PNSR" (numbers: 32 bits, low byte first)
|
||||
// The newest snapshot that checks out is the note as it was last saved. kDone at the very end:
|
||||
// the rewrite this file was for is complete in `<note>.tmp`, and only has to take the note's place.
|
||||
const char kMagic[] = "roro9stack note edits 1\n";
|
||||
constexpr size_t kMagicLen = sizeof(kMagic) - 1;
|
||||
constexpr size_t kHeaderLen = kMagicLen + 12;
|
||||
const char kSnap[] = "RSNP", kSnapEnd[] = "PNSR", kDone[] = "RDONE1\n\n";
|
||||
constexpr size_t kDoneLen = 8;
|
||||
constexpr size_t kCheck = 1024; // of each end of the note, in the header
|
||||
constexpr uint32_t kStepBytes = 64 * 1024; // a rewrite's step
|
||||
constexpr size_t kBlock = 4096;
|
||||
constexpr uint32_t kSeekNewline = 1024;
|
||||
constexpr size_t kMaxSnapshot = 12 + 9 * 4096 + 12;
|
||||
|
||||
bool continuation(int c) { return (c & 0xC0) == 0x80; }
|
||||
|
||||
uint32_t crc32(uint32_t crc, const uint8_t* data, size_t len) {
|
||||
crc = ~crc;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
crc ^= data[i];
|
||||
for (int k = 0; k < 8; k++) crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1)));
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
void put32(std::string& s, uint32_t v) {
|
||||
for (int i = 0; i < 4; i++) s += static_cast<char>((v >> (8 * i)) & 0xFF);
|
||||
}
|
||||
|
||||
uint32_t get32(const uint8_t* p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (static_cast<uint32_t>(p[3]) << 24); }
|
||||
|
||||
const uint8_t* bytes(const std::string& s) { return reinterpret_cast<const uint8_t*>(s.data()); }
|
||||
} // namespace
|
||||
|
||||
NoteDocument::NoteDocument(NoteCard& card, int cols, int rows) : card_(card), text_(cols, rows) { openNew(); }
|
||||
|
||||
void NoteDocument::reset() {
|
||||
path_.clear();
|
||||
sidePath_.clear();
|
||||
pieces_.clear();
|
||||
loaded_.clear();
|
||||
win_ = 0;
|
||||
windowLoaded_ = false;
|
||||
before_ = after_ = 0;
|
||||
droppedAtLoad_ = newlinesAtLoad_ = 0;
|
||||
flushedSinceSave_ = sidePending_ = false;
|
||||
sideSize_ = 0;
|
||||
windowSaved_.valid = false;
|
||||
rw_.active = false;
|
||||
std::vector<uint8_t>().swap(rw_.block);
|
||||
}
|
||||
|
||||
void NoteDocument::openNew() {
|
||||
reset();
|
||||
text_.buffer().clear();
|
||||
text_.refilled(0, 0);
|
||||
windowLoaded_ = true;
|
||||
loadedRevision_ = savedRevision_ = text_.revision();
|
||||
}
|
||||
|
||||
std::string NoteDocument::open(const std::string& path, std::string* told) {
|
||||
openNew();
|
||||
path_ = path;
|
||||
sidePath_ = side();
|
||||
uint32_t sideSize = 0, fileSize = 0, other = 0;
|
||||
bool hasSide = card_.size(sidePath_, sideSize);
|
||||
if (hasSide && sideIsDone(sideSize)) { // a rewrite was cut after its last write: finish it
|
||||
if (card_.size(tmp(), other)) {
|
||||
if (card_.size(path_, fileSize)) card_.remove(path_);
|
||||
card_.rename(tmp(), path_);
|
||||
}
|
||||
card_.remove(sidePath_);
|
||||
hasSide = false;
|
||||
}
|
||||
if (!card_.size(path_, fileSize)) {
|
||||
card_.done();
|
||||
openNew();
|
||||
return "The card refused to open it";
|
||||
}
|
||||
if (fileSize > NoteText::kMaxBytes && card_.freeBytes() < static_cast<uint64_t>(fileSize) + 16 * 1024) {
|
||||
card_.done();
|
||||
openNew();
|
||||
return "Not enough room on the card: saving it needs a second copy";
|
||||
}
|
||||
uint32_t cursor = 0;
|
||||
bool resumed = false;
|
||||
if (hasSide) {
|
||||
if (card_.size(tmp(), other)) card_.remove(tmp()); // a rewrite that didn't get that far
|
||||
sideSize_ = sideSize;
|
||||
Resume r = resume(fileSize, cursor);
|
||||
if (r == Resume::Ok) {
|
||||
resumed = sidePending_ = true;
|
||||
if (told) *told = "Your unsaved changes are back";
|
||||
} else {
|
||||
sideSize_ = 0;
|
||||
pieces_.clear();
|
||||
if (r == Resume::Mismatch) { // typed text is never thrown away without a word (Q227)
|
||||
std::string lost = sidePath_ + ".lost";
|
||||
card_.remove(lost);
|
||||
card_.rename(sidePath_, lost);
|
||||
if (told) *told = "The file changed: unsaved edits kept as .edit.lost";
|
||||
} else {
|
||||
card_.remove(sidePath_);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!resumed && fileSize) pieces_.push_back({0, 0, fileSize});
|
||||
windowLoaded_ = false;
|
||||
bool ok = load(cursor, cursor ? 1000 : 0, -1); // an edit picked up: its last lines above the cursor
|
||||
card_.done();
|
||||
if (!ok) {
|
||||
openNew();
|
||||
return "The card refused to read it";
|
||||
}
|
||||
savedRevision_ = text_.revision();
|
||||
return "";
|
||||
}
|
||||
|
||||
uint32_t NoteDocument::piecesBytes() const {
|
||||
uint32_t n = 0;
|
||||
for (const Piece& p : pieces_) n += p.len;
|
||||
return n;
|
||||
}
|
||||
|
||||
int NoteDocument::percent() const {
|
||||
uint32_t all = size();
|
||||
return all ? static_cast<int>(static_cast<uint64_t>(before_ + text_.top()) * 100 / all) : 0;
|
||||
}
|
||||
|
||||
size_t NoteDocument::readDoc(uint32_t at, uint8_t* into, size_t len) {
|
||||
size_t got = 0;
|
||||
uint32_t pos = 0;
|
||||
for (const Piece& p : pieces_) {
|
||||
if (got == len) break;
|
||||
if (at < pos + p.len) {
|
||||
uint32_t skip = at - pos;
|
||||
size_t n = std::min<size_t>(len - got, p.len - skip);
|
||||
size_t r = card_.read(fileOf(p), p.at + skip, into + got, n);
|
||||
got += r;
|
||||
at += static_cast<uint32_t>(r);
|
||||
if (r != n) break;
|
||||
}
|
||||
pos += p.len;
|
||||
}
|
||||
return got;
|
||||
}
|
||||
|
||||
int NoteDocument::byteAt(uint32_t at) {
|
||||
uint8_t b;
|
||||
return readDoc(at, &b, 1) == 1 ? b : -1;
|
||||
}
|
||||
|
||||
size_t NoteDocument::splitAt(uint32_t at) {
|
||||
uint32_t pos = 0;
|
||||
for (size_t i = 0; i < pieces_.size(); i++) {
|
||||
if (at == pos) return i;
|
||||
Piece& p = pieces_[i];
|
||||
if (at < pos + p.len) {
|
||||
uint32_t first = at - pos;
|
||||
Piece rest{p.src, p.at + first, p.len - first};
|
||||
p.len = first;
|
||||
pieces_.insert(pieces_.begin() + static_cast<long>(i) + 1, rest);
|
||||
return i + 1;
|
||||
}
|
||||
pos += p.len;
|
||||
}
|
||||
return pieces_.size();
|
||||
}
|
||||
|
||||
void NoteDocument::merge() {
|
||||
size_t kept = 0;
|
||||
for (size_t i = 0; i < pieces_.size(); i++) {
|
||||
const Piece p = pieces_[i];
|
||||
if (!p.len) continue;
|
||||
if (kept && pieces_[kept - 1].src == p.src && pieces_[kept - 1].at + pieces_[kept - 1].len == p.at) pieces_[kept - 1].len += p.len;
|
||||
else pieces_[kept++] = p;
|
||||
}
|
||||
pieces_.resize(kept);
|
||||
}
|
||||
|
||||
// The window dropped the CRs of the file's CRLFs when it was read. If it's put back untouched,
|
||||
// the cursor is further along in the file than in the window: by one for each line before it.
|
||||
uint32_t NoteDocument::noteCursor() const {
|
||||
size_t c = text_.cursor();
|
||||
if (windowLoaded_ && droppedAtLoad_ && text_.revision() == loadedRevision_) {
|
||||
const std::string& t = text_.text();
|
||||
if (droppedAtLoad_ == newlinesAtLoad_) c += static_cast<size_t>(std::count(t.begin(), t.begin() + static_cast<long>(c), '\n'));
|
||||
else if (!t.empty()) c += droppedAtLoad_ * c / t.size(); // a file of both kinds of line: near enough
|
||||
}
|
||||
return before_ + static_cast<uint32_t>(c);
|
||||
}
|
||||
|
||||
bool NoteDocument::headerFor(std::string& header) {
|
||||
uint32_t fileSize = 0;
|
||||
if (path_.empty() || !card_.size(path_, fileSize)) return false;
|
||||
std::vector<uint8_t> buf(kCheck);
|
||||
size_t n = std::min<size_t>(kCheck, fileSize);
|
||||
if (card_.read(path_, 0, buf.data(), n) != n) return false;
|
||||
uint32_t head = crc32(0, buf.data(), n);
|
||||
if (card_.read(path_, fileSize - static_cast<uint32_t>(n), buf.data(), n) != n) return false;
|
||||
uint32_t tail = crc32(0, buf.data(), n);
|
||||
header.assign(kMagic, kMagicLen);
|
||||
put32(header, fileSize);
|
||||
put32(header, head);
|
||||
put32(header, tail);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NoteDocument::ensureSide(std::string& why) {
|
||||
if (sideSize_) return true;
|
||||
std::string header;
|
||||
if (!headerFor(header)) {
|
||||
why = path_.empty() ? "the note has no file yet" : "the card refused to read the note";
|
||||
return false;
|
||||
}
|
||||
if (!card_.create(sidePath_) || !card_.append(sidePath_, bytes(header), header.size())) {
|
||||
card_.remove(sidePath_);
|
||||
why = "the card refused a write";
|
||||
return false;
|
||||
}
|
||||
sideSize_ = static_cast<uint32_t>(header.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NoteDocument::putBack(std::string& why) {
|
||||
if (!windowLoaded_) return true;
|
||||
if (text_.revision() == loadedRevision_) {
|
||||
pieces_.insert(pieces_.begin() + static_cast<long>(win_), loaded_.begin(), loaded_.end());
|
||||
} else {
|
||||
Piece p{1, 0, static_cast<uint32_t>(text_.size())};
|
||||
if (windowSaved_.valid && windowSaved_.revision == text_.revision()) {
|
||||
p.at = windowSaved_.at;
|
||||
} else if (p.len) {
|
||||
if (!ensureSide(why)) return false;
|
||||
if (!card_.append(sidePath_, bytes(text_.text()), p.len)) {
|
||||
card_.done();
|
||||
if (!card_.size(sidePath_, sideSize_)) sideSize_ = 0;
|
||||
why = "the card refused a write";
|
||||
return false;
|
||||
}
|
||||
p.at = sideSize_;
|
||||
sideSize_ += p.len;
|
||||
}
|
||||
if (p.len) pieces_.insert(pieces_.begin() + static_cast<long>(win_), p);
|
||||
flushedSinceSave_ = sidePending_ = true;
|
||||
}
|
||||
windowLoaded_ = false;
|
||||
loaded_.clear();
|
||||
windowSaved_.valid = false;
|
||||
before_ = after_ = 0;
|
||||
merge();
|
||||
return true;
|
||||
}
|
||||
|
||||
// The window's start is where a line starts on screen whenever that can be known: after a
|
||||
// newline, or where the window before had a line start. Otherwise the same text could wrap
|
||||
// differently from one window to the next.
|
||||
bool NoteDocument::load(uint32_t cursor, int row, int64_t startHint) {
|
||||
uint32_t total = piecesBytes();
|
||||
cursor = std::min(cursor, total);
|
||||
uint32_t s = 0, e = total;
|
||||
if (total > NoteText::kMaxBytes - kEdge) {
|
||||
uint32_t c = cursor > kHalf ? cursor - kHalf : 0;
|
||||
if (c == 0) {
|
||||
s = 0;
|
||||
} else if (startHint >= 0 && startHint <= static_cast<int64_t>(c)) {
|
||||
s = static_cast<uint32_t>(startHint);
|
||||
} else {
|
||||
uint8_t buf[128];
|
||||
uint32_t at = c, limit = std::min(c + kSeekNewline, cursor);
|
||||
bool found = false;
|
||||
while (at < limit && !found) {
|
||||
size_t n = readDoc(at, buf, std::min<size_t>(sizeof buf, limit - at));
|
||||
if (!n) break;
|
||||
for (size_t i = 0; i < n && !found; i++)
|
||||
if (buf[i] == '\n') {
|
||||
s = at + static_cast<uint32_t>(i) + 1;
|
||||
found = true;
|
||||
}
|
||||
at += static_cast<uint32_t>(n);
|
||||
}
|
||||
if (!found) {
|
||||
s = c;
|
||||
for (int k = 0; k < 3 && s < cursor && continuation(byteAt(s)); k++) s++;
|
||||
}
|
||||
}
|
||||
e = std::min(total, cursor + kHalf);
|
||||
for (int k = 0; k < 3 && e < total && continuation(byteAt(e)); k++) e++;
|
||||
if (e < total && e > 0 && byteAt(e) == '\n' && byteAt(e - 1) == '\r') e++;
|
||||
e = std::min<uint32_t>(e, s + NoteText::kMaxBytes);
|
||||
}
|
||||
size_t i0 = splitAt(s), i1 = splitAt(e);
|
||||
loaded_.assign(pieces_.begin() + static_cast<long>(i0), pieces_.begin() + static_cast<long>(i1));
|
||||
pieces_.erase(pieces_.begin() + static_cast<long>(i0), pieces_.begin() + static_cast<long>(i1));
|
||||
win_ = i0;
|
||||
before_ = s;
|
||||
after_ = total - e;
|
||||
std::string& b = text_.buffer();
|
||||
b.resize(e - s);
|
||||
size_t got = 0;
|
||||
bool ok = true;
|
||||
for (const Piece& p : loaded_) {
|
||||
size_t n = card_.read(fileOf(p), p.at, reinterpret_cast<uint8_t*>(&b[got]), p.len);
|
||||
got += n;
|
||||
if (n != p.len) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ok) { // the note is whole in its pieces: stand on an empty window where the cursor was
|
||||
pieces_.insert(pieces_.begin() + static_cast<long>(i0), loaded_.begin(), loaded_.end());
|
||||
loaded_.clear();
|
||||
win_ = splitAt(cursor);
|
||||
before_ = cursor;
|
||||
after_ = total - cursor;
|
||||
s = cursor;
|
||||
b.clear();
|
||||
}
|
||||
droppedAtLoad_ = text_.refilled(cursor - s, row);
|
||||
newlinesAtLoad_ = static_cast<size_t>(std::count(b.begin(), b.end(), '\n'));
|
||||
loadedRevision_ = text_.revision();
|
||||
windowLoaded_ = true;
|
||||
windowSaved_.valid = false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool NoteDocument::wantsMove() const {
|
||||
if (!windowLoaded_) return true;
|
||||
size_t n = text_.size(), c = text_.cursor();
|
||||
if (n + kSpare >= NoteText::kMaxBytes) return true;
|
||||
if (before_ && c < kEdge) return true;
|
||||
return after_ && n - c < kEdge;
|
||||
}
|
||||
|
||||
bool NoteDocument::move(std::string& why) {
|
||||
uint32_t cursor = noteCursor();
|
||||
int row = text_.cursorRow();
|
||||
int64_t hint = -1;
|
||||
if (windowLoaded_ && !droppedAtLoad_ && cursor > kHalf) {
|
||||
uint32_t c = cursor - kHalf;
|
||||
if (c >= before_ && c < before_ + text_.size()) hint = static_cast<int64_t>(before_) + static_cast<int64_t>(text_.startOfLine(c - before_));
|
||||
}
|
||||
if (!putBack(why)) return false;
|
||||
bool ok = load(cursor, row, hint);
|
||||
card_.done();
|
||||
if (!ok) why = "the card refused to read";
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool NoteDocument::jump(uint32_t to, std::string& why) {
|
||||
to = std::min(to, size());
|
||||
if (windowLoaded_ && to == 0 && !before_) return text_.toStart(), true;
|
||||
if (windowLoaded_ && to == size() && !after_) return text_.toEnd(), true;
|
||||
if (!putBack(why)) return false;
|
||||
bool ok = load(to, to ? 1000 : 0, -1);
|
||||
card_.done();
|
||||
if (!ok) why = "the card refused to read";
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool NoteDocument::wantsRewrite() const { return size() <= kWholeLimit || sideSize_ > kSideLimit || pieces_.size() > kManyPieces; }
|
||||
|
||||
bool NoteDocument::journal(std::string& why) {
|
||||
if (path_.empty()) {
|
||||
why = "the note has no file yet";
|
||||
return false;
|
||||
}
|
||||
bool modified = text_.revision() != loadedRevision_;
|
||||
Piece w{1, 0, static_cast<uint32_t>(text_.size())};
|
||||
uint32_t cursor = noteCursor();
|
||||
if (!ensureSide(why)) return false;
|
||||
bool wrote = true;
|
||||
if (modified && w.len) {
|
||||
if (windowSaved_.valid && windowSaved_.revision == text_.revision()) {
|
||||
w.at = windowSaved_.at;
|
||||
} else if ((wrote = card_.append(sidePath_, bytes(text_.text()), w.len))) {
|
||||
w.at = sideSize_;
|
||||
sideSize_ += w.len;
|
||||
windowSaved_.valid = true;
|
||||
windowSaved_.at = w.at;
|
||||
windowSaved_.len = w.len;
|
||||
windowSaved_.revision = text_.revision();
|
||||
}
|
||||
}
|
||||
if (wrote) {
|
||||
std::vector<Piece> all(pieces_.begin(), pieces_.begin() + static_cast<long>(win_));
|
||||
if (!modified) all.insert(all.end(), loaded_.begin(), loaded_.end());
|
||||
else if (w.len) all.push_back(w);
|
||||
all.insert(all.end(), pieces_.begin() + static_cast<long>(win_), pieces_.end());
|
||||
std::string rec(kSnap, 4);
|
||||
put32(rec, cursor);
|
||||
put32(rec, static_cast<uint32_t>(all.size()));
|
||||
for (const Piece& p : all) {
|
||||
rec += static_cast<char>(p.src);
|
||||
put32(rec, p.at);
|
||||
put32(rec, p.len);
|
||||
}
|
||||
put32(rec, crc32(0, bytes(rec), rec.size()));
|
||||
put32(rec, static_cast<uint32_t>(rec.size()) + 8);
|
||||
rec.append(kSnapEnd, 4);
|
||||
wrote = card_.append(sidePath_, bytes(rec), rec.size());
|
||||
if (wrote) sideSize_ += static_cast<uint32_t>(rec.size());
|
||||
}
|
||||
card_.done();
|
||||
if (!wrote) {
|
||||
windowSaved_.valid = false;
|
||||
if (!card_.size(sidePath_, sideSize_)) sideSize_ = 0;
|
||||
why = "the card refused a write";
|
||||
return false;
|
||||
}
|
||||
savedRevision_ = text_.revision();
|
||||
flushedSinceSave_ = false;
|
||||
sidePending_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NoteDocument::rewriteStart(std::string& why) {
|
||||
if (path_.empty()) {
|
||||
why = "the note has no file yet";
|
||||
return false;
|
||||
}
|
||||
if (card_.freeBytes() < static_cast<uint64_t>(size()) + 16 * 1024) {
|
||||
why = "the card is full";
|
||||
return false;
|
||||
}
|
||||
if (!card_.create(tmp())) {
|
||||
why = "the card refused to open a file";
|
||||
return false;
|
||||
}
|
||||
rw_.active = true;
|
||||
rw_.stage = 0;
|
||||
rw_.index = 0;
|
||||
rw_.offset = rw_.done = rw_.wrote = rw_.wroteBefore = rw_.wroteAfter = 0;
|
||||
rw_.total = size();
|
||||
rw_.revision = text_.revision();
|
||||
rw_.carry = false;
|
||||
rw_.block.resize(kBlock);
|
||||
return true;
|
||||
}
|
||||
|
||||
int NoteDocument::rewriteStep(std::string& why) {
|
||||
if (!rw_.active) return -1;
|
||||
auto failed = [&](const char* what) {
|
||||
card_.done();
|
||||
card_.remove(tmp());
|
||||
rw_.active = false;
|
||||
std::vector<uint8_t>().swap(rw_.block);
|
||||
why = what;
|
||||
return -1;
|
||||
};
|
||||
auto out = [&](const uint8_t* data, size_t len) {
|
||||
if (!len) return true;
|
||||
if (!card_.append(tmp(), data, len)) return false;
|
||||
rw_.wrote += static_cast<uint32_t>(len);
|
||||
if (rw_.stage == 0) rw_.wroteBefore += static_cast<uint32_t>(len);
|
||||
if (rw_.stage == 2) rw_.wroteAfter += static_cast<uint32_t>(len);
|
||||
return true;
|
||||
};
|
||||
const uint8_t cr = '\r';
|
||||
uint32_t budget = kStepBytes;
|
||||
while (budget > 0 && rw_.stage < 3) {
|
||||
if (rw_.stage == 1) {
|
||||
uint32_t left = static_cast<uint32_t>(text_.size()) - rw_.offset;
|
||||
if (!left) {
|
||||
rw_.stage = 2;
|
||||
rw_.index = win_;
|
||||
rw_.offset = 0;
|
||||
continue;
|
||||
}
|
||||
uint32_t n = std::min(left, budget);
|
||||
if (!out(bytes(text_.text()) + rw_.offset, n)) return failed("the card refused a write");
|
||||
rw_.offset += n;
|
||||
rw_.done += n;
|
||||
budget -= n;
|
||||
continue;
|
||||
}
|
||||
size_t end = rw_.stage == 0 ? win_ : pieces_.size();
|
||||
bool pieceOver = rw_.index < end && rw_.offset >= pieces_[rw_.index].len;
|
||||
if (rw_.index >= end || pieceOver) {
|
||||
if (rw_.carry && !out(&cr, 1)) return failed("the card refused a write"); // a CR that ended its piece stays
|
||||
rw_.carry = false;
|
||||
rw_.offset = 0;
|
||||
if (pieceOver) rw_.index++;
|
||||
else rw_.stage++;
|
||||
continue;
|
||||
}
|
||||
const Piece& p = pieces_[rw_.index];
|
||||
uint32_t n = std::min<uint32_t>(std::min<uint32_t>(p.len - rw_.offset, budget), static_cast<uint32_t>(rw_.block.size()));
|
||||
uint8_t* b = rw_.block.data();
|
||||
if (card_.read(fileOf(p), p.at + rw_.offset, b, n) != n) return failed("the card refused to read the note");
|
||||
size_t m = n;
|
||||
if (p.src == 0) { // CRLF becomes LF (Q148, Q230), in the file's own text: what was typed has none
|
||||
if (rw_.carry && b[0] != '\n' && !out(&cr, 1)) return failed("the card refused a write");
|
||||
rw_.carry = false;
|
||||
m = 0;
|
||||
for (uint32_t i = 0; i < n; i++) {
|
||||
if (b[i] == '\r') {
|
||||
if (i + 1 == n) {
|
||||
rw_.carry = true;
|
||||
continue;
|
||||
}
|
||||
if (b[i + 1] == '\n') continue;
|
||||
}
|
||||
b[m++] = b[i];
|
||||
}
|
||||
}
|
||||
if (!out(b, m)) return failed("the card refused a write");
|
||||
rw_.offset += n;
|
||||
rw_.done += n;
|
||||
budget -= n;
|
||||
}
|
||||
if (rw_.stage < 3) return std::min(99, static_cast<int>(static_cast<uint64_t>(rw_.done) * 100 / std::max<uint32_t>(1, rw_.total)));
|
||||
|
||||
// All of it is in the temporary file. From the mark in the side file on, the rewrite counts as
|
||||
// done: whatever is cut after that, opening the note finishes it.
|
||||
card_.done();
|
||||
uint32_t written = 0, old = 0;
|
||||
if (!card_.size(tmp(), written) || written != rw_.wrote) return failed("the card refused a write");
|
||||
if (sideSize_) {
|
||||
if (!card_.append(sidePath_, reinterpret_cast<const uint8_t*>(kDone), kDoneLen)) return failed("the card refused a write");
|
||||
sideSize_ += kDoneLen;
|
||||
card_.done();
|
||||
}
|
||||
rw_.active = false;
|
||||
std::vector<uint8_t>().swap(rw_.block);
|
||||
// FAT can't rename onto a file. Between these two lines only the temporary file exists: the
|
||||
// Notes list puts such a file back under its name.
|
||||
if ((card_.size(path_, old) && !card_.remove(path_)) || !card_.rename(tmp(), path_)) {
|
||||
card_.done();
|
||||
why = "the card refused to replace the note";
|
||||
return -1;
|
||||
}
|
||||
if (sideSize_) card_.remove(sidePath_);
|
||||
card_.done();
|
||||
sideSize_ = 0;
|
||||
uint32_t window = static_cast<uint32_t>(text_.size());
|
||||
pieces_.clear();
|
||||
loaded_.clear();
|
||||
if (rw_.wroteBefore) pieces_.push_back({0, 0, rw_.wroteBefore});
|
||||
win_ = pieces_.size();
|
||||
if (rw_.wroteAfter) pieces_.push_back({0, rw_.wroteBefore + window, rw_.wroteAfter});
|
||||
if (window) loaded_.push_back({0, rw_.wroteBefore, window});
|
||||
before_ = rw_.wroteBefore;
|
||||
after_ = rw_.wroteAfter;
|
||||
loadedRevision_ = savedRevision_ = rw_.revision;
|
||||
droppedAtLoad_ = 0;
|
||||
windowLoaded_ = true;
|
||||
flushedSinceSave_ = sidePending_ = false;
|
||||
windowSaved_.valid = false;
|
||||
return 100;
|
||||
}
|
||||
|
||||
bool NoteDocument::sideIsDone(uint32_t sideSize) {
|
||||
uint8_t tail[kDoneLen];
|
||||
return sideSize >= kHeaderLen + kDoneLen && card_.read(sidePath_, sideSize - kDoneLen, tail, kDoneLen) == kDoneLen &&
|
||||
std::memcmp(tail, kDone, kDoneLen) == 0;
|
||||
}
|
||||
|
||||
NoteDocument::Resume NoteDocument::resume(uint32_t fileSize, uint32_t& cursor) {
|
||||
uint8_t header[kHeaderLen];
|
||||
if (sideSize_ < kHeaderLen || card_.read(sidePath_, 0, header, kHeaderLen) != kHeaderLen || std::memcmp(header, kMagic, kMagicLen) != 0)
|
||||
return Resume::Nothing;
|
||||
|
||||
// The newest snapshot that checks out, looking back from the end: after it there may be text
|
||||
// that left a window, or a write the power cut short.
|
||||
auto snapshotEndingAt = [&](uint32_t end) {
|
||||
uint8_t lenBytes[4];
|
||||
if (end < kHeaderLen + 24 || card_.read(sidePath_, end - 8, lenBytes, 4) != 4) return false;
|
||||
uint32_t len = get32(lenBytes);
|
||||
if (len < 24 || len > kMaxSnapshot || len > end - kHeaderLen || (len - 24) % 9) return false;
|
||||
std::string rec(len, '\0');
|
||||
if (card_.read(sidePath_, end - len, reinterpret_cast<uint8_t*>(&rec[0]), len) != len) return false;
|
||||
const uint8_t* r = bytes(rec);
|
||||
if (std::memcmp(r, kSnap, 4) != 0 || get32(r + len - 12) != crc32(0, r, len - 12)) return false;
|
||||
uint32_t count = get32(r + 8);
|
||||
if (count != (len - 24) / 9) return false;
|
||||
std::vector<Piece> list;
|
||||
list.reserve(count);
|
||||
for (uint32_t i = 0; i < count; i++) {
|
||||
const uint8_t* q = r + 12 + 9 * i;
|
||||
Piece p{q[0], get32(q + 1), get32(q + 5)};
|
||||
uint64_t stop = static_cast<uint64_t>(p.at) + p.len;
|
||||
if (p.src > 1 || !p.len) return false;
|
||||
if (p.src == 1 && (p.at < kHeaderLen || stop > end - len)) return false;
|
||||
list.push_back(p);
|
||||
}
|
||||
pieces_.swap(list);
|
||||
cursor = get32(r + 4);
|
||||
return true;
|
||||
};
|
||||
bool found = false;
|
||||
std::vector<uint8_t> buf(1024 + 3);
|
||||
for (uint32_t end = sideSize_; end > kHeaderLen && !found;) {
|
||||
uint32_t a = end > 1024 + kHeaderLen ? end - 1024 : static_cast<uint32_t>(kHeaderLen);
|
||||
size_t n = card_.read(sidePath_, a, buf.data(), std::min<size_t>(buf.size(), sideSize_ - a));
|
||||
for (size_t i = n >= 4 ? n - 4 + 1 : 0; i-- > 0 && !found;)
|
||||
if (std::memcmp(buf.data() + i, kSnapEnd, 4) == 0) found = snapshotEndingAt(a + static_cast<uint32_t>(i) + 4);
|
||||
end = a;
|
||||
}
|
||||
if (!found) return Resume::Nothing;
|
||||
std::string expect;
|
||||
if (!headerFor(expect) || std::memcmp(header, expect.data(), kHeaderLen) != 0) {
|
||||
pieces_.clear();
|
||||
return Resume::Mismatch;
|
||||
}
|
||||
for (const Piece& p : pieces_)
|
||||
if (p.src == 0 && static_cast<uint64_t>(p.at) + p.len > fileSize) return pieces_.clear(), Resume::Mismatch;
|
||||
merge();
|
||||
return Resume::Ok;
|
||||
}
|
||||
|
||||
} // namespace roro::notes
|
||||
@@ -0,0 +1,130 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "note_text.h"
|
||||
|
||||
namespace roro::notes {
|
||||
|
||||
// The card, as a note needs it. On the device every call is made on the storage task.
|
||||
class NoteCard {
|
||||
public:
|
||||
virtual ~NoteCard() = default;
|
||||
virtual bool size(const std::string& path, uint32_t& size) = 0; // false: no such file
|
||||
virtual size_t read(const std::string& path, uint32_t at, uint8_t* into, size_t len) = 0;
|
||||
virtual bool create(const std::string& path) = 0; // an empty file, in place of what was there
|
||||
virtual bool append(const std::string& path, const uint8_t* data, size_t len) = 0;
|
||||
virtual bool remove(const std::string& path) = 0;
|
||||
virtual bool rename(const std::string& from, const std::string& to) = 0;
|
||||
virtual uint64_t freeBytes() = 0;
|
||||
virtual void done() {} // what was appended is on the card now (files kept open are closed)
|
||||
};
|
||||
|
||||
// A text file of any size, edited (issue #47, F1 Q223-Q232). The file stays on the card; what is
|
||||
// in memory is one window of it, a NoteText of up to 16 KB around the cursor, and a list of pieces
|
||||
// saying what the rest is made of: runs of bytes of the file, and runs of the side file
|
||||
// `<note>.edit`, where a window that was changed is written when the cursor leaves it.
|
||||
//
|
||||
// the note = pieces before the window + the window + pieces after it
|
||||
//
|
||||
// Saving comes in two kinds. `journal` appends the window and the list of pieces to the side
|
||||
// file: quick whatever the note's size, and enough to pick the edit up after a power cut.
|
||||
// `rewrite` streams the whole note into `<note>.tmp` and puts it in the note's place: the file is
|
||||
// then the note again, and the side file goes. A note of up to 64 KB is always rewritten.
|
||||
//
|
||||
// Every method marked [card] reads or writes the card.
|
||||
class NoteDocument {
|
||||
public:
|
||||
static constexpr uint32_t kHalf = 4096; // loaded on each side of the cursor
|
||||
static constexpr uint32_t kEdge = 2048; // this near an end of the window, it moves
|
||||
static constexpr uint32_t kSpare = 256; // this near full, it moves
|
||||
static constexpr uint32_t kWholeLimit = 64 * 1024; // up to here a save is a rewrite
|
||||
static constexpr uint32_t kSideLimit = 1024 * 1024; // a side file this big asks for a rewrite
|
||||
static constexpr size_t kManyPieces = 256; // and so does a list this long
|
||||
|
||||
NoteDocument(NoteCard& card, int cols, int rows);
|
||||
|
||||
// [card] "" or why not. `told`: something the user should read (an edit picked up, or set aside).
|
||||
std::string open(const std::string& path, std::string* told = nullptr);
|
||||
void openNew(); // nothing on the card until the first rewrite
|
||||
const std::string& path() const { return path_; }
|
||||
void setPath(const std::string& path) { // a new note's, before its first rewrite
|
||||
path_ = path;
|
||||
sidePath_ = path.empty() ? "" : side();
|
||||
}
|
||||
|
||||
NoteText& text() { return text_; }
|
||||
const NoteText& text() const { return text_; }
|
||||
uint32_t size() const { return before_ + static_cast<uint32_t>(text_.size()) + after_; }
|
||||
uint32_t cursor() const { return before_ + static_cast<uint32_t>(text_.cursor()); } // in the note
|
||||
int percent() const;
|
||||
bool windowed() const { return before_ || after_; } // the note is more than its window
|
||||
|
||||
// After each key: the cursor is near an end of the window that isn't an end of the note, or
|
||||
// the window is nearly full.
|
||||
bool wantsMove() const;
|
||||
bool move(std::string& why); // [card] the window, around the cursor
|
||||
bool jump(uint32_t to, std::string& why); // [card] the cursor, anywhere in the note
|
||||
|
||||
bool dirty() const { return text_.revision() != savedRevision_ || flushedSinceSave_; } // the card doesn't have it
|
||||
bool filePending() const { return sidePending_; } // saved, but in the side file: a rewrite is owed
|
||||
bool wantsRewrite() const; // the next save should be a rewrite
|
||||
bool journal(std::string& why); // [card]
|
||||
bool rewriteStart(std::string& why); // [card]
|
||||
int rewriteStep(std::string& why); // [card] percent done; 100: the file is the note; -1: failed
|
||||
bool rewriting() const { return rw_.active; }
|
||||
|
||||
private:
|
||||
struct Piece {
|
||||
uint8_t src; // 0: the note's file, 1: the side file
|
||||
uint32_t at, len;
|
||||
};
|
||||
enum class Resume { Ok, Mismatch, Nothing };
|
||||
|
||||
std::string side() const { return path_ + ".edit"; }
|
||||
std::string tmp() const { return path_ + ".tmp"; }
|
||||
const std::string& fileOf(const Piece& p) const { return p.src ? sidePath_ : path_; }
|
||||
uint32_t piecesBytes() const;
|
||||
size_t readDoc(uint32_t at, uint8_t* into, size_t len); // from the pieces: the window is put back first
|
||||
int byteAt(uint32_t at);
|
||||
size_t splitAt(uint32_t at); // the index of the piece that starts there
|
||||
void merge();
|
||||
uint32_t noteCursor() const; // where the cursor is among the pieces once the window is put back
|
||||
bool putBack(std::string& why);
|
||||
bool load(uint32_t cursor, int row, int64_t startHint); // false: the card refused, and the window is empty
|
||||
bool ensureSide(std::string& why);
|
||||
bool headerFor(std::string& header);
|
||||
Resume resume(uint32_t fileSize, uint32_t& cursor);
|
||||
bool sideIsDone(uint32_t sideSize);
|
||||
void reset();
|
||||
|
||||
NoteCard& card_;
|
||||
NoteText text_;
|
||||
std::string path_, sidePath_;
|
||||
std::vector<Piece> pieces_; // without the window while it's loaded
|
||||
std::vector<Piece> loaded_; // what the window was read from
|
||||
size_t win_ = 0; // the window sits before pieces_[win_]
|
||||
bool windowLoaded_ = false;
|
||||
uint32_t before_ = 0, after_ = 0;
|
||||
uint32_t loadedRevision_ = 0, savedRevision_ = 0;
|
||||
size_t droppedAtLoad_ = 0, newlinesAtLoad_ = 0;
|
||||
bool flushedSinceSave_ = false, sidePending_ = false;
|
||||
uint32_t sideSize_ = 0; // 0: no side file
|
||||
struct {
|
||||
bool valid = false;
|
||||
uint32_t at = 0, len = 0, revision = 0;
|
||||
} windowSaved_; // the window as the side file already has it
|
||||
struct {
|
||||
bool active = false;
|
||||
int stage = 0; // 0: pieces before, 1: the window, 2: pieces after
|
||||
size_t index = 0;
|
||||
uint32_t offset = 0, done = 0, total = 0, wrote = 0, wroteBefore = 0, wroteAfter = 0, revision = 0;
|
||||
bool carry = false; // a CR at the end of a block, waiting to see what follows
|
||||
std::vector<uint8_t> block;
|
||||
} rw_;
|
||||
};
|
||||
|
||||
} // namespace roro::notes
|
||||
@@ -16,11 +16,24 @@ NoteText::NoteText(int cols, int rows, std::string&& text) : cols_(std::max(1, c
|
||||
text_.reserve(kMaxBytes);
|
||||
}
|
||||
|
||||
void NoteText::dropCarriageReturns() {
|
||||
size_t kept = 0;
|
||||
for (size_t i = 0; i < text_.size(); i++)
|
||||
if (!(text_[i] == '\r' && i + 1 < text_.size() && text_[i + 1] == '\n')) text_[kept++] = text_[i];
|
||||
size_t NoteText::dropCarriageReturns(size_t* follow) {
|
||||
size_t kept = 0, size = text_.size(), place = follow ? *follow : 0;
|
||||
for (size_t i = 0; i < size; i++) {
|
||||
if (follow && i == place) *follow = kept;
|
||||
if (!(text_[i] == '\r' && i + 1 < size && text_[i + 1] == '\n')) text_[kept++] = text_[i];
|
||||
}
|
||||
if (follow && place >= size) *follow = kept;
|
||||
text_.resize(kept);
|
||||
return size - kept;
|
||||
}
|
||||
|
||||
size_t NoteText::refilled(size_t cursor, int row) {
|
||||
size_t dropped = dropCarriageReturns(&cursor);
|
||||
cursor_ = std::min(cursor, text_.size());
|
||||
while (cursor_ > 0 && cursor_ < text_.size() && continuation(text_[cursor_])) cursor_--;
|
||||
top_ = lineOf(cursor_);
|
||||
for (int i = 0; i < row && top_ > 0; i++) top_ = lineOf(top_ - 1);
|
||||
return dropped;
|
||||
}
|
||||
|
||||
bool NoteText::setText(const std::string& text) {
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
namespace roro::notes {
|
||||
|
||||
// The text of a note while it's edited (F1, Q144, Q145): UTF-8 held whole in memory, a cursor, and
|
||||
// the part of it on screen. Lines wrap at spaces, `cols` characters wide; a line owns the space or
|
||||
// The text of a note while it's edited (F1, Q144, Q145): UTF-8 held in memory, a cursor, and the
|
||||
// part of it on screen. Up to 16 KB: a longer note is edited through NoteDocument (note_document.h),
|
||||
// which keeps this as its window on the file. Lines wrap at spaces, `cols` characters wide; a line owns the space or
|
||||
// the newline it ends with, so every byte of the text belongs to exactly one line. No index of
|
||||
// lines is kept (a note of newlines alone would need twice its size): where a line starts is
|
||||
// worked out from the start of its paragraph, which is never far.
|
||||
@@ -27,8 +28,17 @@ class NoteText {
|
||||
bool setText(const std::string& text);
|
||||
const std::string& text() const { return text_; }
|
||||
size_t cursor() const { return cursor_; }
|
||||
size_t size() const { return text_.size(); }
|
||||
uint32_t revision() const { return revision_; } // changes with every edit: is it saved?
|
||||
|
||||
// For a window on a longer text (issue #47): the caller refills the buffer, then says where
|
||||
// the cursor is in it and which row of the screen it should be on. CRLF becomes LF as in
|
||||
// setText; returns how many CRs went. The revision doesn't change: nothing was edited.
|
||||
std::string& buffer() { return text_; }
|
||||
size_t refilled(size_t cursor, int row);
|
||||
size_t startOfLine(size_t pos) const { return lineOf(pos); }
|
||||
size_t top() const { return top_; }
|
||||
|
||||
bool insert(uint32_t codePoint); // false: the note is full
|
||||
bool insertText(const std::string& s); // all of it or nothing
|
||||
void backspace();
|
||||
@@ -61,7 +71,7 @@ class NoteText {
|
||||
bool hasLineAfter(size_t start) const;
|
||||
void moved(bool keepGoal = false);
|
||||
void follow(); // scrolls so the cursor is on screen
|
||||
void dropCarriageReturns();
|
||||
size_t dropCarriageReturns(size_t* follow = nullptr); // how many; `follow` is a place in the text, kept on its character
|
||||
|
||||
int cols_, rows_;
|
||||
std::string text_;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# Creates the key CI uses to ask the web server for a site refresh, once (issue #79,
|
||||
# docs/milestones/W1.md), and says where each half goes. The private key stays in
|
||||
# ~/.config/roro9stack/ until it is pasted into the Gitea secret; it is never committed and this
|
||||
# script doesn't print it.
|
||||
#
|
||||
# scripts/site_deploy_keygen.sh [/full/path/to/rororefresh.sh] [the runner's address]
|
||||
set -euo pipefail
|
||||
KEY="${RORO_SITE_DEPLOY_KEY:-$HOME/.config/roro9stack/site-deploy-key}"
|
||||
COMMAND="${1:-/full/path/to/rororefresh.sh}"
|
||||
FROM="${2:-}"
|
||||
|
||||
if [ -e "$KEY" ]; then
|
||||
echo "A site deploy key already exists at $KEY; not overwriting it." >&2
|
||||
else
|
||||
mkdir -p "$(dirname "$KEY")"
|
||||
( umask 077; ssh-keygen -q -t ed25519 -N "" -C roro9stack-ci-site-refresh -f "$KEY" )
|
||||
fi
|
||||
|
||||
options="restrict,command=\"$COMMAND\""
|
||||
[ -z "$FROM" ] || options="from=\"$FROM\",$options"
|
||||
|
||||
cat <<TEXT
|
||||
|
||||
1. On the web server, as the user that runs the refresh, add this one line to ~/.ssh/authorized_keys:
|
||||
|
||||
$options $(cat "$KEY.pub")
|
||||
|
||||
restrict: no terminal, no forwarding of any kind. command=: whatever the client asks for, this
|
||||
runs instead.$([ -n "$FROM" ] || printf '\n Give the runner'"'"'s address as the second argument to add from="...": the key then works from there only.')
|
||||
|
||||
2. In Gitea, the repository's Settings > Actions > Secrets:
|
||||
|
||||
SITE_DEPLOY_KEY the whole of $KEY (the private key, with its BEGIN and END lines)
|
||||
SITE_DEPLOY_HOST the server's address as the runner reaches it, or address:port
|
||||
SITE_DEPLOY_USER that user's name
|
||||
SITE_DEPLOY_KNOWN_HOSTS the server's host key, one line, from a machine you trust the network of:
|
||||
ssh-keyscan -t ed25519 <address> (or: -p <port> <address>)
|
||||
and compare it with the server's own:
|
||||
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub (on the server)
|
||||
ssh-keyscan -t ed25519 <address> | ssh-keygen -lf - (here)
|
||||
|
||||
3. Try it, from here, with the same four values in the environment:
|
||||
|
||||
SITE_DEPLOY_KEY="\$(cat $KEY)" SITE_DEPLOY_HOST=... SITE_DEPLOY_USER=... \\
|
||||
SITE_DEPLOY_KNOWN_HOSTS="\$(ssh-keyscan -t ed25519 ... 2>/dev/null)" scripts/site_refresh.sh
|
||||
|
||||
(with from= set, this works from the runner's address only.) Then, to see that the key can do
|
||||
nothing else: ssh -i $KEY <user>@<address> id must run the refresh, not \`id\`.
|
||||
|
||||
Once the secret is in Gitea, the copy at $KEY can be deleted.
|
||||
TEXT
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Asks the web server to rebuild the site (issue #79, docs/milestones/W1.md). Run by CI after a push
|
||||
# to main that changed the site, and after a release is published (the home page and Downloads
|
||||
# name the latest release when they are built).
|
||||
#
|
||||
# It only connects: the server's authorized_keys line forces the one command this key may run, so
|
||||
# nothing sent from here chooses what happens there. From the environment (Gitea secrets):
|
||||
# SITE_DEPLOY_KEY the private key (scripts/site_deploy_keygen.sh makes it)
|
||||
# SITE_DEPLOY_HOST the server, or server:port
|
||||
# SITE_DEPLOY_USER the user there
|
||||
# SITE_DEPLOY_KNOWN_HOSTS the server's host key, as a known_hosts line: nothing else is trusted
|
||||
# With none of them set it does nothing (a fork, or before the key is installed); with only some, it fails.
|
||||
set -euo pipefail
|
||||
|
||||
set_count=0
|
||||
for v in SITE_DEPLOY_KEY SITE_DEPLOY_HOST SITE_DEPLOY_USER SITE_DEPLOY_KNOWN_HOSTS; do
|
||||
[ -z "${!v:-}" ] || set_count=$((set_count + 1))
|
||||
done
|
||||
if [ "$set_count" = 0 ]; then
|
||||
echo "site refresh: no SITE_DEPLOY_* secrets here, nothing done"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$set_count" != 4 ]; then
|
||||
echo "site refresh: SITE_DEPLOY_KEY, _HOST, _USER and _KNOWN_HOSTS are needed, and only $set_count of them are set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v ssh >/dev/null; then
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends openssh-client >/dev/null
|
||||
fi
|
||||
|
||||
host="$SITE_DEPLOY_HOST" port=22
|
||||
case "$host" in
|
||||
*:*) port="${host##*:}" host="${host%:*}" ;;
|
||||
esac
|
||||
|
||||
# The key and the host key exist as files only while this runs, in a container that goes with the job.
|
||||
umask 077
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
printf '%s\n' "$SITE_DEPLOY_KEY" > "$tmp/key"
|
||||
printf '%s\n' "$SITE_DEPLOY_KNOWN_HOSTS" > "$tmp/known_hosts"
|
||||
|
||||
# -F none: no configuration but this line. -T and no command: the server's forced command runs.
|
||||
ssh -F none -T -p "$port" -i "$tmp/key" \
|
||||
-o IdentitiesOnly=yes -o BatchMode=yes \
|
||||
-o StrictHostKeyChecking=yes -o UserKnownHostsFile="$tmp/known_hosts" -o GlobalKnownHostsFile=/dev/null \
|
||||
-o ConnectTimeout=20 -o ServerAliveInterval=15 -o ServerAliveCountMax=8 \
|
||||
"$SITE_DEPLOY_USER@$host"
|
||||
echo "site refresh: done"
|
||||
@@ -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>
|
||||
crash the last crash: firmware, reason, task, backtrace
|
||||
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
|
||||
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 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
|
||||
@@ -62,7 +62,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
|
||||
| Command | Effect |
|
||||
|---|---|
|
||||
| `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) |
|
||||
| `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) |
|
||||
| `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) |
|
||||
|
||||
@@ -11,7 +11,7 @@ Everything the keyboard can do, a command can do, and everything on the screen c
|
||||
## 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
|
||||
```
|
||||
|
||||
@@ -20,7 +20,7 @@ Two things to know before you use them:
|
||||
1. **A name `key` does not know is `select`.** `key sleect` presses Enter. A single character is typed as that character; anything else that is not a known name is treated as Enter. Check what you type.
|
||||
2. **A key that wakes a dark screen only wakes it.** The device's power policy swallows the key press that turns the screen back on, as it does for the real keyboard: the first `key` after the screen went off does nothing else. Send `key back` (harmless) first, or keep the screen on with the `normal` and `short` commands below.
|
||||
|
||||
`Fn` combinations, modifiers and the compose key have no command: the arrows are `key up|down|left|right`, and `key back` is the back key (`` ` `` on the device). Text is typed one character at a time.
|
||||
**Ctrl, Alt and Shift** go before the name: `key ctrl-down`, `key alt-up`, `key ctrl-b`, `key shift-alt-down`. `Fn` combinations and the compose key have no command: the arrows are `key up|down|left|right` (what `Fn` with `;` `.` `,` `/` gives on the device), and `key back` is the back key (`` ` `` on the device). Text is typed one character at a time.
|
||||
|
||||
**`key help` opens the help panel** (<kbd>Fn</kbd>+<kbd>h</kbd> on the device): the keys of the screen that is showing. A screenshot of it is the quickest way to learn what a screen accepts, and it is how every screen's list was checked. Any key but the arrows closes it.
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ Plain text notes on the SD card, written on the device. Q30 settled the base: `.
|
||||
| Q141 | A **Notes** App in the Launcher. One row per note: its first line as the title, then the date. Newest first; `s` switches to by name. `n` new, Enter opens, `d` deletes after a confirmation, `r` renames the file. |
|
||||
| Q142 | A new note's file name is never typed: it comes from the first line when the note is first saved (`shopping-list.txt`), or `note-20261006-0919.txt` if that line is empty. It doesn't change afterwards unless the note is renamed. |
|
||||
| Q143 | **Autosave, no "discard changes?" prompt:** five seconds after the last key, on leaving the note or the App, and when the screen turns off. A save writes a temporary file and renames it over the note, so a power cut loses the last few seconds at most. A temporary file left behind is offered back at the next open. |
|
||||
| Q144 | The whole note is in memory while it's edited, up to **16 KB**. A bigger text file opens read-only in the Storage App's viewer. The App refuses to open below the memory floors (Q86). **Editing files of any size must come in a later release: issue #47.** |
|
||||
| Q144 | The whole note is in memory while it's edited, up to **16 KB**. A bigger text file opens read-only in the Storage App's viewer. The App refuses to open below the memory floors (Q86). *(Lifted by issue #47: see "Notes of any size" below.)* **Editing files of any size must come in a later release: issue #47.** |
|
||||
| Q145 | The editor wraps at spaces, 38 columns by 8 rows, with a line for the name and the state. Enter is a new line, Del deletes backwards, Fn+arrows move (the Text Entry rule), Ctrl+A and Ctrl+E go to the start and the end of the line, Tab types two spaces, Back saves and returns. The Compose Key works as elsewhere. |
|
||||
| Q146 | The Storage App's text viewer gets `e`: edit this file with the same editor, for a text file up to 16 KB that isn't read-only. That lifts Q135 without Apps opening each other (#43 stays). |
|
||||
| Q147 | The list is flat: the files directly in `/notes`. Sub-folders are reached through the Storage App. |
|
||||
@@ -167,3 +167,177 @@ Test notes were made in `/notes` and removed afterwards; the folder is left, emp
|
||||
**Not checked:** accents through the Compose Key and Ctrl+A / Ctrl+E (the remote `key` command can't send them; the model's tests cover both), the power button's save (it needs a hand on the device), a missing card, and how typing feels on the keyboard itself.
|
||||
|
||||
**One slip during the checks:** a key sequence sent right after a restart opened IRC instead of Notes, and the test letters went into IRC's input line. Nothing was sent: the line was cleared and the App left. IRC connected to Libera as it does when opened.
|
||||
|
||||
## Notes of any size (issue #47)
|
||||
|
||||
Q144 held the whole note in memory and stopped at 16 KB, for the first version only. This lifts it: the editor opens a text file whatever its size.
|
||||
|
||||
### Decisions (design round 2026-10-07)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q223 | **The note is the file on the card plus one window in memory.** The window is the `NoteText` of before, up to 16 KB around the cursor; the rest is a list of pieces: runs of the file, and runs of a side file. The cursor leaving the window writes it to the side file if it was changed, and loads the next. Typing never fills a note: a full window is written away and loaded smaller. |
|
||||
| Q224 | **The five-second save:** up to 64 KB it rewrites the file, as before (about 150 ms). Above, it appends the window and the list of pieces to `<note>.edit`: 8 KB or so, whatever the note's size. "saved" means "on the card" either way. |
|
||||
| Q225 | **The file itself is rewritten on leaving the note** (Back, Home, another App), with a progress bar. The screen turning off and the device powering off write the side file only: powering off never waits. |
|
||||
| Q226 | **After a power cut, opening the note picks the edit up** where it was last saved, without a question, and says so. Until then the file has the old text for anything else that reads it. |
|
||||
| Q227 | If the file was changed elsewhere meanwhile, the side file no longer fits it: it is **kept as `<note>.edit.lost`** and the editor says so. Typed text is never deleted without a word. |
|
||||
| Q228 | **No limit but the card:** a note over 16 KB needs room for a second copy to be opened for editing. No warning for a big file; the progress bar on leaving tells the cost. |
|
||||
| Q229 | A side file over 1 MB, or a list of over 256 pieces, makes the next save a rewrite. |
|
||||
| Q230 | **CRLF becomes LF** (Q148) for a long file too: in the window as it is read, and in the rest of the file as the rewrite streams it, so a saved file is never of both kinds. |
|
||||
| Q231 | **One path.** A 16 KB note is the case with no pieces: there is no second editor for small notes. |
|
||||
| Q232 | Notes, and `e` in the Storage App's viewer, which no longer says "Too big to edit". |
|
||||
|
||||
### As built
|
||||
|
||||
- **`NoteDocument`** (`lib/notes/src/note_document.h`, host-tested against a card in memory) is the list of pieces, the window's moves, the side file and the recovery. `NoteText` is unchanged but for being refilled.
|
||||
- **The window moves** when the cursor comes within 2 KB of an end of it that isn't an end of the note: it is then 4 KB on each side of the cursor. It starts where a line starts on screen whenever that can be known (after a newline, or where the window before had a line start), so the same text wraps the same from one window to the next, and never in the middle of a character. The cursor keeps its row on screen.
|
||||
- **Looking writes nothing:** a window that wasn't changed goes back as the pieces it was read from.
|
||||
- **The side file** starts with a line of text, the note's size and checksums of its first and last kilobyte, which is how a file changed elsewhere is told. After that, text that left a window, and snapshots of the list of pieces, each with its checksum. The newest snapshot that checks out is the note as last saved; anything after it is ignored.
|
||||
- **The rewrite** streams the pieces and the window into `<note>.tmp`, checks its size, then writes a mark at the end of the side file: from that mark on, the rewrite counts as done, and opening the note finishes it whatever was cut (remove the old file, rename, remove the side file). Before the mark, the note and its side file are still the truth and the temporary file is dropped.
|
||||
- **On the device** the card is reached through an adapter that keeps the file being read and the file being appended to open between calls; every call runs on the storage task while the main loop waits. The rewrite runs in steps of 64 KB with the progress drawn between them.
|
||||
- **The Notes list** doesn't show `.edit` and `.edit.lost` files, and a note's side file is deleted and renamed with it.
|
||||
- **Ctrl with Fn+Up and Fn+Down** go to the start and the end of the note.
|
||||
- **`key ctrl-down`**: the consoles' `key` command takes `ctrl-`, `alt-` and `shift-`, which these checks needed. It also lets the checks S1 couldn't make (Ctrl+b, the Alt scroll) be made.
|
||||
- **Cost:** 15 KB of flash. Memory with a note open is what it was: 17.5 KB, for 62 bytes or for 1.2 MB.
|
||||
|
||||
### Host tests (15, `test/test_note_document`)
|
||||
|
||||
A walk down 3,000 lines and back up through the windows; start and end; an edit in the middle rewritten into the file; 48 KB typed into a new note; a journal picked up after a cut; **a cut at every 997th byte of a sequence of two saves and a rewrite**, after which the note is always one of the three texts it should be, what was reported saved is there, and no stray file is left; a file changed elsewhere; CRLF; windows on text with no space and no newline, made of 2, 3 and 4-byte characters; a full card; and **36,000 random keys** (typing, deleting, moving, jumping, saving, power cuts) on six notes of 30 to 130 KB, compared with a plain string after every key.
|
||||
|
||||
### Checks on the device (2026-10-07, driven over the Debug Console)
|
||||
|
||||
Test notes were copied to `/notes` and removed afterwards; the note that was already there was not touched.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| A 36 KB note | Opens (it was refused before). Two letters at the top, 400 lines down across the windows, four more: the file fetched back is exactly that, and no other file is left |
|
||||
| A 1.2 MB note | Opens at once. Free memory 104.2 KB before, 86.7 KB with it open |
|
||||
| Its five-second save | `zz-big.txt.edit`, 4 KB; the note's file untouched |
|
||||
| Ctrl with Down, Ctrl with Up | The end and the start, as fast as any key |
|
||||
| A restart with unsaved keys | "Your unsaved changes are back", the cursor where it was, the unsaved keys gone and nothing else |
|
||||
| Leaving it | The progress bar, then one file: **1.2 MB rewritten in 2.6 s**. Fetched back: the original with what was typed at both ends, byte for byte |
|
||||
| A restart in the middle of that rewrite | The note, its side file and an empty `.tmp` remain; opening picks the edit up, leaving rewrites it, the result is right |
|
||||
| A new note | No file until typed in, then `zz-test-note.txt` from its first line |
|
||||
| `e` in the Storage App on the 1.2 MB file | The same editor; edited and rewritten |
|
||||
| The Notes list | Side files are not listed as notes |
|
||||
|
||||
**Not checked:** the power button's path (side file only), the screen turning off, a card pulled while editing, and memory with IRC connected, which wasn't connected for these checks: the editor's own use hasn't changed, and it still refuses to open without a free block of 24 KB. The real keyboard's Ctrl with Fn and the arrows. A file of tens of megabytes. Renaming or deleting a note from the Storage App leaves its side file behind.
|
||||
|
||||
**Measured against what was said:** the first build rewrote 1.2 MB in 3.5 to 4.5 s, with 2 KB blocks. With 4 KB blocks it is 2.6 s, about 450 KB a second, which is what the card gives a plain copy.
|
||||
|
||||
## Pictures in the Storage App (issue #45)
|
||||
|
||||
Q139 left images out: the firmware wrote none. Since the Shell's `screenshot` it does.
|
||||
|
||||
### Decisions (design round 2026-10-07)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q233 | **PNG, JPEG, BMP and GIF.** A GIF shows its first picture; it doesn't move. |
|
||||
| Q234 | *Revised while building.* **Our own PNG decoder**, a row at a time, with the window the file's compression asks for: 32 KB at most. The plan was the display library's, which takes 44 KB in one block: after one picture the largest free block was 43 to 47 KB, and every second PNG was refused. **The firmware's own screenshots** are read with no decoding at all: they are stored uncompressed, each byte already a colour of the screen. |
|
||||
| Q235 | **The picture is decoded once, straight into the screen's buffer, and left there.** No copy in memory (it would be up to 30 KB). `App::retainsContent()` tells the screen not to clear the App's part; `contentLost()` tells the App that it was cleared after all, or that a Toast or the help panel drawn over it has gone: then it is decoded again. |
|
||||
| Q236 | **Shrunk to fit; Enter shows it at its own size**, the arrows then moving half a screen. A picture smaller than the screen sits in the middle at its size. |
|
||||
| Q237 | **Ordered dithering** to the screen's 256 colours (a 4 x 4 pattern). A colour the screen has exactly comes out as itself wherever it lands, so a screenshot isn't touched. |
|
||||
| Q238 | Shrinking takes, for each pixel of the screen, the first of the picture's that falls on it. No averaging: there is nowhere to keep the sums. Thin lines break up; a JPEG looks better, its decoder halving it up to three times first. |
|
||||
| Q239 | **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. The rotation a camera stores in the file is ignored. |
|
||||
| Q240 | *Revised while building.* **Decoding runs on the storage task while the main loop goes on.** The picture appears as it comes, and anything that needs the screen back stops the decoding first. The plan was to wait for it, behind a "Decoding..." line: 12 megapixels took longer than the watchdog allows the main loop to stand still, and the device restarted. |
|
||||
| Q241 | The Storage App: Enter on `.png`, `.jpg`, `.jpeg`, `.bmp`, `.gif`, or on a file with no known extension whose first bytes say what it is. Tab gives the hex. `i`, and opening, show the size in pixels on the last line for three seconds. |
|
||||
| Q242 | Not in this one: animation, opening a picture from the Gemini App, a slideshow. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/files/src/image_file.h`** (host-tested): what a file is and how big, where each pixel lands (`ImageFrame`, `ImageMap`), the dithering, and readers for BMP and GIF that hand their pixels on as they get them. **`png_reader.h`**: the PNG decoder, with its own inflate: every colour type and bit depth, palettes with transparency. Transparent pixels are left as the background.
|
||||
- **`ImagePane`** (`src/apps/image_pane`) is the view. One decoding is a `Job` shared with the storage task; `cancel()` flags it and waits behind it in the task's queue, which is how the screen is known to be free again.
|
||||
- **JPEG is the one decoder that isn't ours:** the display library's TJpgDec, with its 3.9 KB pool. It shrinks by 2, 4 or 8 while decoding, which is why a photograph is possible at all.
|
||||
- **A decoder stops early** once the rest of the file is below the screen (a picture at its own size), and a BMP's rows that aren't shown aren't read.
|
||||
- **The note** on the last line is written over the picture; the strip under it (2.6 KB) is kept and put back, so showing it costs no decoding.
|
||||
- **A BMP is read in the order its rows are stored**, last row first: reading it top to bottom meant going back through the file for every row, a second for 135 rows.
|
||||
- **Cost:** 21 KB of flash. Nothing while no picture is shown.
|
||||
|
||||
### Measured on the device
|
||||
|
||||
| Picture | Fitted | Its own size |
|
||||
|---|---|---|
|
||||
| A screenshot of ours, 240 x 135 | 80 ms | 78 ms |
|
||||
| PNG, 800 x 600 | 855 ms | 575 ms |
|
||||
| PNG with transparency, 800 x 600 | 1,098 ms | |
|
||||
| JPEG, 800 x 600 | 305 ms | |
|
||||
| JPEG, 4000 x 3000 (2.6 MB) | 6.9 s | 7.7 s (the middle of it) |
|
||||
| GIF, 800 x 600 | 642 ms | |
|
||||
| BMP, 800 x 600 (1.4 MB) | 991 ms | |
|
||||
| BMP, 240 x 135 | 124 ms | |
|
||||
|
||||
Free memory fell to 48.8 KB at the lowest while a PNG was decoded, from 104 KB. The storage task's stack: 3.2 KB never used, of 6.
|
||||
|
||||
### Host tests (20, `test/test_image_file` and `test/test_png_reader`)
|
||||
|
||||
The pictures in them were made with Pillow, so the readers are checked against an encoder that isn't ours: GIFs plain, interlaced, transparent and long enough for the codes to reach 12 bits; BMPs of 8 and 24 bits; PNGs of every kind (colour, with alpha, palette of 8 and 4 bits with a transparent entry, greys of 1, 8 and 16 bits, grey with alpha, not compressed, and one that refers 21,600 bytes back). Every reader is also fed its file with a byte changed, at every few bytes: an answer each time, and no pixel outside the picture.
|
||||
|
||||
### Checks on the device (2026-10-07, driven over the Debug Console)
|
||||
|
||||
Test pictures were copied to a scratch folder and removed afterwards, with the test screenshot.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Colour bars as PNG, JPEG, BMP and GIF, 240 x 135 and 800 x 600 | The same picture each time, the colours in the right order |
|
||||
| A PNG with a transparent square | The square is the background |
|
||||
| A screenshot taken in the Shell | Shown; at its own size it is the screen, pixel for pixel |
|
||||
| A progressive JPEG | Hex, with "A progressive JPEG can't be shown" |
|
||||
| An animated GIF | Its first picture |
|
||||
| 12 megapixels | Arrives from the top down in 6.9 s; the size is noted when it is whole |
|
||||
| Enter, then the arrows | Its own size from the middle, then half a screen at a time |
|
||||
| Back in the middle of a decoding | The folder's listing at once |
|
||||
| Tab to hex and back, three times; the help panel, then closed | The picture again each time |
|
||||
|
||||
**Not checked:** a photograph from a real camera (the test JPEGs were made by Pillow); a Toast over a picture; a PNG with IRC connected, when there may not be the memory; the card pulled while decoding; the real keyboard.
|
||||
|
||||
### What went wrong while building it
|
||||
|
||||
**The watchdog.** The first version waited for the decoder. The main loop is watched: five seconds without a pass and the device restarts, which is what it did on the 12-megapixel test. The crash report named the decoder's line. The decoding moved to the background, which also made the picture appear as it comes.
|
||||
|
||||
**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.
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -29,7 +29,7 @@ The home page was designed on a canvas in a Claude chat (a dark and a light them
|
||||
| Q175 | The site lives in this repository, in `site/`, so the documentation is built from `docs/`, `CONTEXT.md` and the README instead of being copied. |
|
||||
| Q176 | **Zola,** like the blog. The design becomes a template, its tokens CSS custom properties. Dark and light follow the visitor's setting, with a visible switch. No JavaScript except the flasher's. |
|
||||
| Q177 | Domain: **roro9stack.net.** The blog stays at experiments.twis.la. |
|
||||
| Q178 | **Publishing is the blog's way:** the web server pulls `main` and runs `zola build`; that part is the maintainer's. Changes reach `main` through pull requests as everywhere. **CI is split:** a dedicated `site` job builds the site (`zola build`) when `site/`, `docs/`, `README.md` or `CONTEXT.md` change, and the firmware tests and builds skip a change that touches nothing else. A change that touches both runs both. |
|
||||
| Q178 | **Publishing is the blog's way:** the web server pulls `main` and runs `zola build`; that part is the maintainer's. *(Since issue #79, CI asks the server to do it: see "Published by CI" below.)* Changes reach `main` through pull requests as everywhere. **CI is split:** a dedicated `site` job builds the site (`zola build`) when `site/`, `docs/`, `README.md` or `CONTEXT.md` change, and the firmware tests and builds skip a change that touches nothing else. A change that touches both runs both. |
|
||||
| Q179 | Phases, each its own pull request: **1.** the CI split, the home page, an Install page with the browser flasher, downloads and the changelog. **2.** a user guide page per App. **3.** how-tos and the FAQ. **4.** developer docs generated from the repository. |
|
||||
| Q180 | **A browser flasher** (ESP Web Tools), **without copying the firmware.** Caddy, in front of Gitea, adds `Access-Control-Allow-Origin: https://roro9stack.net` (and `Vary: Origin`) to GET and HEAD on `/twisla/roro9stack/releases/download/*` and `/api/v1/repos/twisla/roro9stack/releases*`: both are public already. The Install page asks the API for the latest release in the browser, finds the asset ending `-factory.bin`, and gives ESP Web Tools a manifest built on the spot, so it offers a new release as soon as it exists, with no rebuild. The library is **vendored** into `site/static/` (Apache-2.0), not loaded from a CDN. The file's SHA-256 is shown on the page. Chrome or Edge on a desktop only; other browsers, and visitors without JavaScript, get the `esptool` steps on the same page. |
|
||||
| Q181 | Docs for the latest version only. The changelog is the Gitea releases, read at build time. |
|
||||
@@ -133,3 +133,60 @@ Not one of the planned phases: the blog's seven roro9stack posts, imported into
|
||||
- **Left out on purpose:** the M0 and M1 milestone documents and `CONTEXT.md` (the glossary) describe Wi-Fi monitoring, which the site does not publish. They stay in the repository.
|
||||
- **The Debug Console pages were written against the source and the live console:** the protocol (the token line, the banner, the 4 KB backlog, one client, 8 queued commands, 240-byte lines, `denied` after a second) and the replies shown were checked on a Debug Build, v0.11.0-3, over Wi-Fi. Not run: `crash abort`, `crash wdt` and Safe Mode, which are described from ADR 0005 and the code.
|
||||
- **Found while writing it:** the README's table lacked the `gnss` commands (rows added); piping commands into `rdbg.py` returns before the replies unless the input stays open (documented, not changed); `update install` on a Debug Build needs `force` (documented).
|
||||
|
||||
## Published by CI (issue #79, design round 2026-10-07)
|
||||
|
||||
Q178 left publishing to the maintainer: a merge, then a command typed on the web server, each time.
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q214 | **A plain ed25519 key with a forced command**, not a certificate: one line in the web server user's `authorized_keys`, `restrict,command="/full/path/to/the/refresh"`. `restrict` takes away the terminal and every forwarding. A certificate could carry the same and an expiry date, at the price of a CA to keep and a key to sign again each time: too much for one key and one command. |
|
||||
| Q215 | **The CI sends no command.** The server runs the forced one whatever is asked for, so there is nothing to keep secret about it and nothing a leaked key could choose. The full path is written once, on the server (a command over SSH doesn't get the user's login `PATH`). |
|
||||
| Q216 | Four secrets: `SITE_DEPLOY_KEY`, `SITE_DEPLOY_HOST` (or `host:port`), `SITE_DEPLOY_USER`, and **`SITE_DEPLOY_KNOWN_HOSTS`**, the server's host key: the job connects to that server or to nothing. None of them is in the repository, which is public. |
|
||||
| Q217 | `from="<the runner's address>"` on the same line: the key works from the runner only. |
|
||||
| Q218 | **The last step of the Site workflow**, after the build and the checks, on a push to `main` only. A pull request never reaches it, and the secrets are given to that step alone. |
|
||||
| Q219 | **After a release too.** The Install page asks Gitea for the latest release when it is opened, but the home page and Downloads read it when the site is built: so the release workflow refreshes the site once the release is published. |
|
||||
| Q220 | A refresh that fails makes the run red, with what the server's script printed: it has to exit with an error when it fails. |
|
||||
| Q221 | Two refreshes at once are the server script's to refuse or queue (`flock`). |
|
||||
| Q222 | The key is a file only while the step runs, in the job's container, as the signing key is. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`scripts/site_refresh.sh`** is what both workflows run: it writes the key and the host key to a temporary folder, connects with no configuration but its own line (`-F none`, strict host key checking, that one key, no command), and removes them. With none of the four secrets it does nothing and says so (a fork, or a repository without them); with only some it fails.
|
||||
- **`scripts/site_deploy_keygen.sh`** makes the key pair once, in `~/.config/roro9stack/`, and prints the `authorized_keys` line and what goes in each secret. It never prints the private key.
|
||||
- **The server's script** should start like this, for Q220 and Q221:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
exec 9>/tmp/rororefresh.lock
|
||||
flock -w 120 9
|
||||
```
|
||||
|
||||
### Checks (2026-10-07, against an SSH server in a throwaway container)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| The refresh | The forced command runs as the server's user; the script ends with `site refresh: done` |
|
||||
| The same key, asking for `id; cat /etc/passwd` | The refresh runs instead; what was asked for is only handed to it as text |
|
||||
| A terminal | Refused: `PTY allocation request failed` |
|
||||
| `scp` with the key | Nothing is copied |
|
||||
| Another host key in the secret | `Host key verification failed`, the run fails, nothing is sent |
|
||||
| The server's script exits with an error | So does the step |
|
||||
| No secrets at all; only one of the four | Does nothing and says so; fails and says which are needed |
|
||||
|
||||
**Not checked:** the real web server and the runner, which wait for the key to be installed: whether the runner reaches the server's SSH port is the first thing the first run will tell. Port forwarding, which `restrict` switches off, was not tried. `from=` was not tried either.
|
||||
|
||||
## Search (issue #60)
|
||||
|
||||
A search over the documentation: the user guide, the how-tos, the questions and answers, and the developer docs. Not the devlog.
|
||||
|
||||
- **The index is the search page itself** (`/search/`, `templates/search.html`): one list item for each page and for each `##` heading of it, with that part's text, written by Zola from the pages' own content when the site is built. Nothing is fetched and nothing typed leaves the browser, so the Content-Security-Policy needs nothing new, and the web server still only runs `zola build`.
|
||||
- **Without JavaScript** the page is a list of every page and heading of the documentation, each a link.
|
||||
- **With it**, `js/search.js` filters and ranks the items as you type: every word has to be in the part; a word in a heading counts for most, the words side by side for more than scattered, and the user guide, the how-tos and the FAQ come before the developer docs, the milestones last. A result links to its heading, with the text around the match.
|
||||
- **The content pages get no script for it:** the navigation has a link, and the index pages of the guide, the how-tos and the developer docs have a box that is a plain form to `/search/?q=`.
|
||||
- **Size:** about 245 items, about 310 KB of HTML, under 100 KB compressed, loaded only by who searches.
|
||||
|
||||
**Checked** in Chromium with the production Content-Security-Policy on every response, no violation: "probation" (the guide's "Probation and Rollback" first), "safe mode", "rm -r", "how big can a note" (the FAQ's question first), a word that isn't there; typing, following a result to its heading, the box on the guide's index, 390 px wide with no sideways scroll, and JavaScript off. `check_site.py` follows every link of the page, so an index entry can't point at a heading that doesn't exist.
|
||||
|
||||
**Not checked:** other browsers, and a screen reader.
|
||||
|
||||
|
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 %}
|
||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,264 @@
|
||||
+++
|
||||
title = '''It said "No"'''
|
||||
description = '''The last post listed three things as next for roro9stack: one help key, a shell on the device, notes of any size. All three shipped in a day, with a CI that stopped rebuilding the world and a website that publishes itself. On the way, a test script kept typing after the device had crashed, and sent one word to an IRC channel full of people.'''
|
||||
date = 2026-10-07T18:00:00+02:00
|
||||
|
||||
[extra]
|
||||
topics = '''ESP32-S3 · Testing · Editors'''
|
||||
read_label = '''Read who it said it to →'''
|
||||
uid = '''<b>app:</b> Shell <b>heap:</b> 104 KB free'''
|
||||
dek = "Three releases of [roro9stack](/devlog/roro9stack/) in one day: v0.13.0, v0.14.0 and v0.15.0. A help key that replaces every hint line, the firmware's console on the device's own screen, and an editor that opens a megabyte in the memory it used for a shopping list. Most of what went wrong was me being wrong about what the device had done. One thing was the device doing exactly what my script told it to, in the wrong App."
|
||||
byline = '''designed by interrogation, rounds thirteen to sixteen: thirty-seven questions, two of them answered twice'''
|
||||
|
||||
[extra.sign]
|
||||
label = "Messages sent to real people by a test script"
|
||||
note = "One word, in an IRC channel, after a crash the script didn't notice."
|
||||
count = "1"
|
||||
tone = "red"
|
||||
|
||||
[[extra.cast]]
|
||||
name = "Fn+h"
|
||||
role = "the help key, on every screen"
|
||||
text = "Lists the keys that work where you are. Every screen had a line at the bottom doing that, differently and never completely. Those lines are gone."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The Shell"
|
||||
role = "an App, since v0.14.0"
|
||||
text = "The commands I had been typing from a PC over Wi-Fi, on the device's own keyboard. It took more than one try to decide what it should show, and one crash to decide where its commands run."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The test script"
|
||||
role = "types keys over the Debug Console"
|
||||
text = "Tireless, exact, and with no idea what is on the screen. It typed the right letters. The App under them had changed."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The window"
|
||||
role = "8 KB of a note, around the cursor"
|
||||
text = "All of a note that is in memory. The rest stays on the card, described by a short list. It moves when the cursor nears its edge, and nobody is meant to notice."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "<note>.edit"
|
||||
role = "the side file"
|
||||
text = "Where a long note's changes wait, four kilobytes at a time, until the note is left and rewritten. Also what a power cut leaves behind, on purpose."
|
||||
+++
|
||||
|
||||
## TL;DR
|
||||
|
||||
- The [last post](/devlog/roro9stack-console/) ended with three things as "next". **All three are in**: a help key (**v0.13.0**), a shell on the device (**v0.14.0**), notes of any size (**v0.15.0**).
|
||||
- **Fn+h lists the keys of the screen you're on**, and every hint line is gone. The same lists make the key tables on this website.
|
||||
- **CI went from over seven minutes to about one** for a pull request. It had been rebuilding the whole framework at every run because of one file that isn't in git.
|
||||
- **The Shell** runs the firmware's commands on the device: Tab completes every word and paths on the card, `rm` behaves like Unix's and asks first, `*` and `?` work.
|
||||
- **The editor opens any file.** A 1.2 MB note uses the same 17.5 KB as a 62-byte one, saves in 4 KB pieces, and is rewritten in 2.6 s when you leave it. A power cut at any byte leaves the note or the last save, never something in between.
|
||||
- **This site publishes itself** when a change is merged, through an SSH key that can do exactly one thing.
|
||||
- A test script **sent the word "No" to an IRC channel**. That one can't be fixed, only prevented.
|
||||
- 507 host tests, 39 more than last time.
|
||||
|
||||
## The cast
|
||||
|
||||
{{ cast() }}
|
||||
|
||||
## One key instead of a hint line on every screen
|
||||
|
||||
Every screen had a line at the bottom: `Enter open d delete r rename`. Each was written by hand, each was different, and none had room for everything. The screen is 240 pixels wide.
|
||||
|
||||
So: **Fn+h, everywhere**, and `?` wherever you aren't typing text. It opens a panel over the App, titled with where you are, listing that screen's keys and then the ones that work everywhere. Any other key closes it.
|
||||
|
||||
{{ figure(src="help.png", alt="The Cardputer's screen at 2x: a panel titled Keys: Shell, listing Enter run the line, Tab complete the command, Fn semicolon and period lines you typed before, Alt semicolon and period scroll back and forward, Ctrl b, Fn comma and slash move the cursor, Del delete backwards, help every command.", width=480, height=270, caption="The Shell's keys, from the first build that had a Shell. The line that runs off the edge was reworded the same afternoon.") }}
|
||||
|
||||
The hint lines went, all of them, with one exception: the first-start Setup keeps its own, because someone in their first minute doesn't know the help key exists. It tells them on its first and last screens.
|
||||
|
||||
The lists started as code inside each App. They are now **data in one file**, 52 small tables, and the same script that builds the [developer docs](/dev/) reads that file and writes the key tables in the [user guide](/guide/). CI fails if the site's copy is out of date, so the guide can't list a key the firmware doesn't have.
|
||||
|
||||
## The framework that was rebuilt every time
|
||||
|
||||
A pull request took over seven minutes to check, and a release thirteen and a half. For a firmware that builds in 77 seconds on my machine.
|
||||
|
||||
Where the time went, for one pull request:
|
||||
|
||||
{% table() %}
|
||||
| Step | Time |
|
||||
|---|---|
|
||||
| Tools | 15 s |
|
||||
| Host tests and coverage | 55 s |
|
||||
| **The firmware** | **358 s** |
|
||||
| of which: configuring ESP-IDF | 87 s |
|
||||
| of which: compiling ESP-IDF's libraries | 171 s |
|
||||
| of which: our own code | 91 s |
|
||||
{% end %}
|
||||
|
||||
roro9stack rebuilds the Arduino framework with its own settings, for [smaller TLS buffers](/dev/decisions/0006-framework-rebuilt-for-smaller-tls-buffers/). The rebuilt libraries were sitting in the runner's cache the whole time. But the build system decides whether they still match by reading a file in the project folder, and that file is generated: it isn't in git. Every fresh checkout had no such file, so every run concluded the libraries were stale and rebuilt them. 260 seconds, each time, to produce what was already there.
|
||||
|
||||
The fix is to keep that file with the libraries it describes. Two more things came out of looking:
|
||||
|
||||
- **The version was a `-D` flag on every compiler command line.** Every commit changes the version, so every commit recompiled every file, on my machine too, and no cache could ever have helped. It's now one generated header that one file includes.
|
||||
- **A release built the firmware twice**: once to check it, once to sign it.
|
||||
|
||||
With a build cache for pull requests on top: **27 seconds** for the firmware with one file changed, and about a minute for the whole run on the real runner. A release takes under three minutes, and still compiles its own sources from nothing: no published file contains an object built for another commit.
|
||||
|
||||
## A shell, and what it should show
|
||||
|
||||
The Debug Console's commands are the tool I use most, and they needed a PC. The Shell is an App that runs them on the device.
|
||||
|
||||
The first version was an afternoon's work and wrong in three ways.
|
||||
|
||||
**It showed too much.** The firmware prints all the time: IRC connecting, a packet heard, whatever a PC on the USB port is asking for. Version one showed everything printed in the ten seconds after a command, on the theory that the reply would be in there somewhere. It was, among everything else. The fix was to stop guessing: the console now knows **who each line is for**. A command run from the Shell prints as the Shell's, and so does an answer that arrives a second later from another task, which notes who asked. Ctrl+b shows everything, for when that's what you want.
|
||||
|
||||
**`rm` was dangerous in a new way.** Over the console, `rm <folder>` had always removed the folder and everything in it, which is fine for a script and less fine for a thumb on a small keyboard. It's now Unix's: a folder needs `-r`, and in the Shell it asks unless you say `-f`.
|
||||
|
||||
**Tab did one word.** It now follows the firmware's own `help` text, word by word: `lora st` becomes `lora status`, `gnss track ` lists `start stop`. The words are read from the help text as it is written, so a new command completes without anyone maintaining a table. Past the command, it completes paths on the SD card. And `*` and `?` work in file names.
|
||||
|
||||
{{ figure(src="rm.png", alt="The Cardputer's screen at 2x: a dialog titled Delete? reading The 5 that match /gt2/*. It can't be undone. with two buttons, Cancel selected and Delete.", width=480, height=270, caption="`rm /gt2/*` in the Shell: one question for all five, with Cancel selected. `/gt2` is a scratch folder, made for the purpose.") }}
|
||||
|
||||
One more addition, small and my favourite: **an App's name with a capital letter opens it.** `Notes`, `Irc`, `Storage`. Every command is lowercase, so the capital is the whole syntax.
|
||||
|
||||
## It said "No"
|
||||
|
||||
The Shell's first version ran each command from inside the key handler. I test the UI by sending key presses over the Debug Console, so the call chain was: the main loop, a remote command, a key, the App manager, the Shell, the command interpreter *a second time*, the file command, and `printf` under all of it. The main loop has under 2 KB of stack to spare. `rm` on a folder went past it.
|
||||
|
||||
The device crashed, and restarted, as it should. It came back up in the Launcher.
|
||||
|
||||
My test script didn't know. It had a list of keys to send and it sent them. Its next Enter, meant for the Shell, landed in the Launcher and opened the first App in the list. That is IRC, which connected, as it's configured to, and joined its channels.
|
||||
|
||||
A few lines later the script reached its test of the capital-letter feature: type `No`, press Tab to complete it to `Notes`, press Enter. Tab completes nothing in IRC. Enter sends.
|
||||
|
||||
One word, to a channel of real people, from my nick. Not harmful, not explainable either, and not something any commit can take back.
|
||||
|
||||
Two things changed that afternoon:
|
||||
|
||||
- **The Shell hands its line to the main loop**, which runs it at the same depth as any console command. The crash is gone, and the crash report had decoded to exactly that chain of calls.
|
||||
- **`info` reports the App in front**, and the test helper checks it before every line it types. A script that survives a restart is typing somewhere else, and now it stops.
|
||||
|
||||
The rule I'd had since the day before was "take a screenshot before any key that deletes something". It was the right rule for the wrong failure. Typing is also an action.
|
||||
|
||||
## A megabyte in 17 KB
|
||||
|
||||
The Notes editor held the whole note in memory and stopped at 16 KB. That was a limit for the first version only; [F1's notes](/dev/milestones/f1/) say so in bold.
|
||||
|
||||
The device has no spare memory to throw at this, so the design is the old one from editors that ran on less: **the note is the file on the card, plus one window in memory.** The window is about 8 KB around the cursor. Everything else is a list of pieces: "bytes 0 to 40,000 of the file", "then 9,000 bytes of what was typed". When the cursor nears the window's edge, the window is written away if it changed, and the next one is loaded.
|
||||
|
||||
What makes it usable is what it writes, and when:
|
||||
|
||||
{% table() %}
|
||||
| | Up to 64 KB | Above |
|
||||
|---|---|---|
|
||||
| The save, five seconds after the last key | The whole file, as before | What changed, appended to `<note>.edit`: about 4 KB |
|
||||
| Leaving the note | Nothing more to do | The file is rewritten, with a progress bar |
|
||||
| After a power cut | The note as last saved | The note opens with the saved changes back |
|
||||
{% end %}
|
||||
|
||||
{{ figure(src="saving.png", alt="The Cardputer's screen at 2x, all black with Saving in blue, the file name zz-big.txt, a progress bar a little over half full, and 60%.", width=480, height=270, caption="Leaving a 1.2 MB note. This takes 2.6 seconds, which is long enough to deserve a bar and short enough that I had to race the screenshot.") }}
|
||||
|
||||
Memory with a note open is 17.5 KB, for a note of 62 bytes or of 1.2 MB. Going to the end of the megabyte takes as long as any other key.
|
||||
|
||||
### The part that has to be right
|
||||
|
||||
An editor that loses text is worse than no editor, and this one now has a side file, a temporary file and the note itself, any of which can be half written when the power goes. So the rewrite ends with a mark: once the complete new file is on the card, one small write to the side file says "done". Before that mark, the old note and its side file are the truth. After it, the new file is, and whatever was interrupted is finished the next time the note is opened.
|
||||
|
||||
That is a claim, and it's the kind I don't trust until something has tried to break it. Two tests do:
|
||||
|
||||
- **A power cut at every 997th byte** of two saves and a rewrite. After each, the note has to be one of exactly three texts, the one that was reported as saved has to be there, and no stray file may be left.
|
||||
- **36,000 random keys** on six notes, typing, deleting, moving, jumping, saving and cutting the power, compared with a plain string after every key.
|
||||
|
||||
They pass. But the first run had three failures, and they're worth a line each, because only one of them was the editor's:
|
||||
|
||||
1. I had worked out by hand where the cursor should be after a recovery, and got it wrong by four.
|
||||
2. I had assumed windows would break between groups of three characters in my test text. They break between characters, which is all they promise.
|
||||
3. **A file replaced by a shorter one lost its pending edits without a word.** The design says they are set aside as `.edit.lost` and the editor tells you. The code checked the pieces against the new file's length first, found them out of range, concluded there was nothing valid to keep, and deleted them. A real bug, in exactly the path that exists to never delete typed text.
|
||||
|
||||
Two of three were the test being wrong. The third is why the tests exist.
|
||||
|
||||
{{ figure(src="back.png", alt="The Cardputer's Notes editor at 2x, showing zz-big.txt, 1.1 MB, saved. The first line reads YTOP line 000000, followed by line 000001 to line 000007. At the bottom, in orange: Your unsaved changes are back.", width=480, height=270, caption="After a restart in the middle of a rewrite. The `Y` was typed, saved to the side file, and the device was reset while it was writing the megabyte. It's there.") }}
|
||||
|
||||
### What I had promised, and what I measured
|
||||
|
||||
I'd said the rewrite would take about two and a half seconds a megabyte. The first build took 3.5 to 4.5 seconds for 1.2 MB. It was copying in 2 KB blocks. With 4 KB blocks it takes 2.6, about 450 KB a second, which is what this card gives a plain copy.
|
||||
|
||||
## A site that publishes itself
|
||||
|
||||
Until this morning, publishing this site meant logging into the web server and running a script, by hand, after every merge.
|
||||
|
||||
Now CI does it, after a merge and after a release. The interesting part is what the key in CI is allowed to do, which is one thing:
|
||||
|
||||
{% code(caption="One line of `authorized_keys` on the web server. Whatever the client asks for, this runs instead.") %}
|
||||
```
|
||||
from="<the runner>",restrict,command="/path/to/rororefresh.sh" ssh-ed25519 AAAA… roro9stack-ci
|
||||
```
|
||||
{% end %}
|
||||
|
||||
My first plan had the command as a secret in CI, next to the key. With a forced command there is nothing to keep secret: CI connects and sends no command at all, and a stolen key can refresh the website and do nothing else. The server's address, the user, the key and the server's host key are secrets; the repository is public and none of them is in it.
|
||||
|
||||
Checked against a throwaway SSH server before the real one: asking for `id; cat /etc/passwd` runs the refresh. No terminal. `scp` copies nothing. A different host key stops the run.
|
||||
|
||||
On its first real run, the live page changed fifteen seconds after the run started. This post got here that way.
|
||||
|
||||
## The device was right
|
||||
|
||||
A pattern from the day, three times over.
|
||||
|
||||
**The keys that vanished.** Twice, the first key my script sent after a quiet minute did nothing. The [F1 notes](/dev/milestones/f1/) describe that exact bug, fixed. I wrote it up as a possible regression. It isn't: a key that wakes a dark screen only wakes it, same as on the real keyboard. That's documented, on a page of this site, which I wrote.
|
||||
|
||||
**The letters in the wrong place.** In the editor test I typed two letters at the top of a note, moved 400 lines down, typed four more, fetched the file and compared it with what I expected. It differed: `liMID ne 000400` where I expected `MID line 000400`. The file matched the screen exactly. Moving down keeps the cursor's column, as it should, and I had typed two letters first.
|
||||
|
||||
**The "No".** The device did what it was sent. Every key arrived, in order.
|
||||
|
||||
Three times the instrument was right and the reading was wrong. The remote `key` command now takes `ctrl-`, `alt-` and `shift-`, by the way, because checking the editor's jump to the end of a note needed Ctrl, and "the remote `key` command can't send that" had been in the not-checked list of every milestone since the editor existed.
|
||||
|
||||
## What I didn't check
|
||||
|
||||
- **The real keyboard**, for Fn+h, `?`, Ctrl+b and the Alt scroll. Everything was driven by remote keys.
|
||||
- **The power button's path** in the editor, which writes the side file only, and the screen turning off.
|
||||
- **Memory with IRC connected** and a long note open. After the morning, I didn't connect IRC.
|
||||
- **A file of tens of megabytes.**
|
||||
- Renaming or deleting a note from the Storage App leaves its side file behind. The Notes App handles both.
|
||||
|
||||
## By the numbers
|
||||
|
||||
{% table() %}
|
||||
| | |
|
||||
|---|---|
|
||||
| Releases | 3 |
|
||||
| Design questions | 37 |
|
||||
| Host tests | 507 |
|
||||
| A pull request's CI run, before and after | over 7 min, about 1 |
|
||||
| Seconds spent rebuilding what was already built, per run | 260 |
|
||||
| Flash the Shell costs | 21 KB |
|
||||
| Flash notes of any size cost | 15 KB |
|
||||
| Memory with a note open, 62 bytes or 1.2 MB | 17.5 KB |
|
||||
| Bytes a long note's save writes | about 4,000 |
|
||||
| Random keys the editor was compared against a string for | 36,000 |
|
||||
| Bugs those tests found in the editor | 1 |
|
||||
| Bugs they found in my arithmetic | 2 |
|
||||
| Words sent to an IRC channel | 1 |
|
||||
{% 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. ~~One help key, and CI in a minute.~~ v0.13.0, this post.
|
||||
|
||||
10. ~~The Shell.~~ v0.14.0, this post.
|
||||
|
||||
11. ~~Notes of any size, and a site that publishes itself.~~ v0.15.0, this post.
|
||||
|
||||
12. Next: M4, the mesh, which still wants a second node. And the editor, now that it opens anything, plainly lacks undo.
|
||||
{% end %}
|
||||
|
||||
{% signoff() %}
|
||||
The last post promised three things and this one delivers them, which would be a tidy story if a script of mine hadn't said "No" to a room of strangers halfway through. The device did nothing wrong all day. It crashed where I had written a crash, restarted as designed, and typed what it was sent.
|
||||
{% end %}
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -59,13 +59,17 @@ 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/).
|
||||
|
||||
## 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?
|
||||
|
||||
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/).
|
||||
|
||||
## How big can a note be?
|
||||
|
||||
Up to 16 KB while it is edited. A larger text file opens read-only in the [Storage App](/guide/storage/). Editing a text file of any size is planned.
|
||||
Any size the card has room for. The editor only keeps the part around the cursor in memory, so a megabyte of text opens at once. A long note is rewritten when you leave it, which takes about a second for each 450 KB. See [Long notes](/guide/notes/#long-notes).
|
||||
|
||||
## Why can't I rename or delete some folders?
|
||||
|
||||
|
||||
@@ -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>;</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>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>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 é |
|
||||
|
||||
@@ -22,7 +22,7 @@ Each note shows its first line and its date, newest first. <kbd>s</kbd> switches
|
||||
|
||||
## The editor
|
||||
|
||||
Type. <kbd>Enter</kbd> starts a line and <kbd>Del</kbd> deletes backwards. <kbd>Fn</kbd> with the arrow keys moves the cursor through the wrapped text, <kbd>Ctrl</kbd>+<kbd>A</kbd> and <kbd>Ctrl</kbd>+<kbd>E</kbd> go to the start and the end of the line, <kbd>Tab</kbd> types two spaces, and the compose key gives accents as everywhere.
|
||||
Type. <kbd>Enter</kbd> starts a line and <kbd>Del</kbd> deletes backwards. <kbd>Fn</kbd> with the arrow keys moves the cursor through the wrapped text, <kbd>Ctrl</kbd>+<kbd>A</kbd> and <kbd>Ctrl</kbd>+<kbd>E</kbd> go to the start and the end of the line, <kbd>Ctrl</kbd> with <kbd>Fn</kbd> and up or down to the start and the end of the note, <kbd>Tab</kbd> types two spaces, and the compose key gives accents as everywhere.
|
||||
|
||||
**There is no save key.** The note is written five seconds after your last key, when you press Back, when you leave the App, when the screen turns off and before the device powers off. The top line says `typing` or `saved`.
|
||||
|
||||
@@ -32,9 +32,21 @@ Each save writes a temporary file and then puts it in the note's place, so a pow
|
||||
|
||||
A new note has no file until you type something. The file is then named after its first line (`shopping-list.txt`), or `note-<date>-<time>.txt` if that line gives no usable name.
|
||||
|
||||
## Long notes
|
||||
|
||||
**A note can be any size.** The editor keeps the part around the cursor in memory and the rest on the card, so a file of a megabyte opens as fast as a short one and uses no more memory.
|
||||
|
||||
What changes with size is how it is saved:
|
||||
|
||||
- **Up to 64 KB**, every save rewrites the file, as above.
|
||||
- **Above**, the five-second save writes only what you changed, to a file next to the note (`<note>.edit`). The note itself is rewritten **when you leave it**, with a progress bar: about a second for each 450 KB.
|
||||
- **After a power cut**, or if the device was switched off with the note open, opening the note again brings your saved changes back, and says so. Until then the file itself still has the old text, if you look at it from a computer.
|
||||
|
||||
Saving a long note needs room on the card for a second copy of it. If the file was replaced by something else while its changes were waiting, they can't be applied: they are kept as `<note>.edit.lost` and the editor tells you.
|
||||
|
||||
## Limits
|
||||
|
||||
A note holds up to **16 KB** while it is edited. A bigger text file opens read-only in the [Storage App](/guide/storage/); editing a file of any size is planned. In Storage, <kbd>e</kbd> on a text file opens it in the same editor, anywhere on the card, unless the file is read-only. Notes are never offered for deletion by the clean-up.
|
||||
In Storage, <kbd>e</kbd> on a text file opens it in the same editor, anywhere on the card, unless the file is read-only. Notes are never offered for deletion by the clean-up.
|
||||
|
||||
## The keys, as the device lists them
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ screenshot the screen, now
|
||||
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
|
||||
|
||||
|
||||
@@ -34,12 +34,39 @@ The App says why when it refuses.
|
||||
|
||||
<kbd>Enter</kbd> on a file opens it by its type, and <kbd>Tab</kbd> switches the same file to a hex dump or to text:
|
||||
|
||||
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only a screenful is read from the card, so a file of any size opens at once, and logs open at the end. Up and down move a line, left and right a page, <kbd>t</kbd> and <kbd>b</kbd> go to the top and the end, and <kbd>e</kbd> edits it (up to 16 KB, as in [Notes](/guide/notes/)).
|
||||
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only a screenful is read from the card, so a file of any size opens at once, and logs open at the end. Up and down move a line, left and right a page, <kbd>t</kbd> and <kbd>b</kbd> go to the top and the end, and <kbd>e</kbd> edits it, whatever its size (as in [Notes](/guide/notes/)).
|
||||
- **Pictures** (`.png`, `.jpg`, `.bmp`, `.gif`): see [Pictures](#pictures) below.
|
||||
- **Captures** (`.pcap`): the packets as the [LoRa Scanner](/guide/lora-scanner/) lists them; <kbd>Enter</kbd> shows one with its Meshtastic header and bytes.
|
||||
- **Tracks** (`.gpx`): the number of points, the start, the duration and the distance.
|
||||
- **Update files** (`.ota`): the version, and whether the file would install: it is checked as an install checks it, signature and contents, without writing anything. <kbd>Enter</kbd> then installs it (see [Updates](/guide/updates/)).
|
||||
- **Anything else:** a hex dump.
|
||||
|
||||
## Pictures
|
||||
|
||||
<kbd>Enter</kbd> on a PNG, a JPEG, a BMP or a GIF shows it. A picture bigger than the screen is shrunk to fit; <kbd>Enter</kbd> again shows it **at its own size**, and the arrow keys then move around it, half a screen at a time. <kbd>i</kbd> gives its size in pixels, and <kbd>Tab</kbd> the file as hex.
|
||||
|
||||
- **The screen has 256 colours.** A photograph is dithered to them; a drawing or a screenshot usually has colours the screen shows exactly. The screenshots the [Shell](/guide/shell/) saves are shown pixel for pixel at their own size.
|
||||
- **A big photograph takes time**, because every pixel of the file goes through the decoder, shown or not: about 7 seconds for 12 megapixels. The picture appears as it is decoded, and the keys keep working: Back leaves at once.
|
||||
- **A GIF shows its first picture only.** It doesn't move.
|
||||
- **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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
@@ -50,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.
|
||||
|
||||
{{ keys(scopes=["storage", "storage-details", "storage-name", "storage-busy", "maintenance", "viewer-text", "viewer-hex", "viewer-pcap", "viewer-packet", "viewer-gpx", "viewer-ota"]) }}
|
||||
{{ 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"]) }}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
+++
|
||||
title = "Search"
|
||||
description = "Find a word in the user guide, the how-tos, the questions and answers, and the developer docs."
|
||||
template = "search.html"
|
||||
+++
|
||||
@@ -9,6 +9,7 @@ rows = [
|
||||
["Fn `", "home, the Launcher"],
|
||||
["; . , /", "arrows (Fn+ while typing)"],
|
||||
["Fn h ?", "these keys (? not typing)"],
|
||||
["Fn p", "a screenshot, on the card"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
@@ -248,9 +249,17 @@ rows = [
|
||||
["i", "details: size, date, type"],
|
||||
["s", "sort: name, date, size"],
|
||||
["m", "Maintenance: clean-up, erase"],
|
||||
["w", "share with a browser"],
|
||||
["`", "the folder above"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "storage-share"
|
||||
title = "Storage, sharing with a browser"
|
||||
rows = [
|
||||
["`", "stop sharing"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "storage-details"
|
||||
title = "Storage, an item's details"
|
||||
@@ -291,7 +300,7 @@ rows = [
|
||||
["; .", "a line up, down"],
|
||||
[", /", "a page up, down"],
|
||||
["t b", "the top, the end"],
|
||||
["e", "edit it (up to 16 KB)"],
|
||||
["e", "edit it"],
|
||||
["Tab", "the file as hex, or back"],
|
||||
]
|
||||
|
||||
@@ -338,6 +347,16 @@ rows = [
|
||||
["Tab", "the file as hex"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "viewer-image"
|
||||
title = "A picture"
|
||||
rows = [
|
||||
["Enter", "its own size, or all of it"],
|
||||
["; . , /", "move around it"],
|
||||
["i", "its size"],
|
||||
["Tab", "the file as hex"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "notes"
|
||||
title = "Notes, the list"
|
||||
@@ -360,6 +379,7 @@ rows = [
|
||||
["Tab", "two spaces"],
|
||||
["Fn ; . , /", "move the cursor"],
|
||||
["Alt Fn ; .", "a page up, down"],
|
||||
["Ctrl Fn ; .", "start, end of the note"],
|
||||
["Ctrl a e", "start, end of the line"],
|
||||
["opt ' e", "an accent: é"],
|
||||
["`", "done: it saves by itself"],
|
||||
|
||||
@@ -263,3 +263,26 @@ footer small { display: block; max-width: 760px; }
|
||||
.prose .keys td { padding: 4px 8px 4px 0; border-bottom: 0; font-size: 15px; }
|
||||
.prose .keys td:first-child { white-space: nowrap; width: 1%; padding-right: 16px; }
|
||||
.prose .keys kbd { white-space: pre; }
|
||||
|
||||
/* Search (issue #60): the search page's box, its results and its index; the small box on the
|
||||
documentation's index pages. */
|
||||
.search-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 24px 0 8px; max-width: 720px; }
|
||||
.search-form label { flex-basis: 100%; font: 400 14px/20px var(--mono); color: var(--muted); }
|
||||
.search-form input { flex: 1 1 220px; min-width: 0; min-height: 44px; padding: 0 12px; font: 400 16px/24px var(--sans); color: var(--ink); background: var(--s2); border: 1px solid var(--line); }
|
||||
.search-form input:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
|
||||
.search-mini { margin: 16px 0 32px; max-width: 520px; }
|
||||
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.s-status { min-height: 24px; margin: 8px 0; }
|
||||
.s-results, .s-index ul { list-style: none; margin: 0; padding: 0; max-width: 720px; }
|
||||
.s-results li { padding: 16px 0; border-top: 1px solid var(--line); }
|
||||
.s-results a { font: 500 18px/24px var(--sans); }
|
||||
.s-results p { margin: 4px 0 0; color: var(--muted); overflow-wrap: anywhere; }
|
||||
.s-results mark { background: none; color: var(--orangetext); font-weight: 600; }
|
||||
.s-where { display: block; font: 400 12px/16px var(--mono); color: var(--muted); }
|
||||
.s-index section { margin: 0 0 32px; }
|
||||
.s-index h2 { font: 500 14px/20px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--cyantext); }
|
||||
.s-index li { padding: 2px 0; }
|
||||
.s-index li.s-part { padding-left: 20px; }
|
||||
.s-index .s-where, .s-index .s-text { display: none; }
|
||||
.s-index a:hover { text-decoration: underline; text-underline-offset: 4px; }
|
||||
.js .s-nojs { display: none; }
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// The search page (issue #60). The index is the page itself: one list item for each page of the
|
||||
// documentation and each of its headings, with that part's text. This filters and ranks them.
|
||||
// Nothing is fetched, and nothing typed here leaves the browser.
|
||||
(function () {
|
||||
// Where a match counts for more: what a user came for before what a developer wrote down.
|
||||
var weight = { "Guide": 1.4, "How-to": 1.3, "FAQ": 1.3, "Decisions": 0.8, "Milestones": 0.5 };
|
||||
var kMax = 40, kAround = 90;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var input = document.getElementById("q"), results = document.getElementById("s-results");
|
||||
var status = document.getElementById("s-status"), index = document.getElementById("s-index");
|
||||
if (!input || !results || !index) return;
|
||||
|
||||
var entries = Array.prototype.map.call(index.querySelectorAll(".s-entry"), function (li) {
|
||||
var link = li.querySelector("a"), where = li.querySelector(".s-where"), text = li.querySelector(".s-text");
|
||||
var body = text ? text.textContent.replace(/\s+/g, " ").trim() : "";
|
||||
return {
|
||||
href: link.getAttribute("href"), title: link.textContent, where: where ? where.textContent : "",
|
||||
body: body, titleLow: link.textContent.toLowerCase(), whereLow: (where ? where.textContent : "").toLowerCase(),
|
||||
bodyLow: body.toLowerCase(), weight: weight[li.getAttribute("data-group")] || 1
|
||||
};
|
||||
});
|
||||
|
||||
function count(hay, word) {
|
||||
var n = 0, at = hay.indexOf(word);
|
||||
while (at >= 0 && n < 5) { n++; at = hay.indexOf(word, at + word.length); }
|
||||
return n;
|
||||
}
|
||||
|
||||
function search(words) {
|
||||
var hits = [], phrase = words.join(" ");
|
||||
entries.forEach(function (e) {
|
||||
// The words as typed, side by side, count for more than the same words scattered.
|
||||
var score = words.length > 1 ? (e.titleLow.indexOf(phrase) >= 0 ? 30 : 0) + (e.bodyLow.indexOf(phrase) >= 0 ? 12 : 0) : 0;
|
||||
if (e.titleLow === phrase) score += 20;
|
||||
for (var i = 0; i < words.length; i++) {
|
||||
var w = words[i], inTitle = e.titleLow.indexOf(w) >= 0, inWhere = e.whereLow.indexOf(w) >= 0, n = count(e.bodyLow, w);
|
||||
if (!inTitle && !inWhere && !n) return; // every word has to be there
|
||||
score += (inTitle ? 20 : 0) + (inWhere ? 3 : 0) + n;
|
||||
}
|
||||
hits.push({ entry: e, score: score * e.weight });
|
||||
});
|
||||
hits.sort(function (a, b) { return b.score - a.score; });
|
||||
return hits;
|
||||
}
|
||||
|
||||
// The text around the first word found, with every word marked.
|
||||
function snippet(e, words) {
|
||||
var p = document.createElement("p"), first = -1;
|
||||
words.forEach(function (w) {
|
||||
var at = e.bodyLow.indexOf(w);
|
||||
if (at >= 0 && (first < 0 || at < first)) first = at;
|
||||
});
|
||||
var from = Math.max(0, (first < 0 ? 0 : first) - kAround), to = Math.min(e.body.length, from + 2 * kAround + 40);
|
||||
if (from > 0) { var space = e.body.indexOf(" ", from); if (space >= 0 && space < from + 20) from = space + 1; }
|
||||
var piece = e.body.slice(from, to), low = piece.toLowerCase(), at = 0;
|
||||
if (from > 0) p.appendChild(document.createTextNode("… "));
|
||||
while (at < piece.length) {
|
||||
var next = -1, len = 0;
|
||||
words.forEach(function (w) {
|
||||
var i = low.indexOf(w, at);
|
||||
if (i >= 0 && (next < 0 || i < next)) { next = i; len = w.length; }
|
||||
});
|
||||
if (next < 0) { p.appendChild(document.createTextNode(piece.slice(at))); break; }
|
||||
if (next > at) p.appendChild(document.createTextNode(piece.slice(at, next)));
|
||||
var mark = document.createElement("mark");
|
||||
mark.textContent = piece.slice(next, next + len);
|
||||
p.appendChild(mark);
|
||||
at = next + len;
|
||||
}
|
||||
if (to < e.body.length) p.appendChild(document.createTextNode(" …"));
|
||||
return p;
|
||||
}
|
||||
|
||||
function show() {
|
||||
var q = input.value.trim(), words = q.toLowerCase().split(/\s+/).filter(function (w) { return w.length > 0; });
|
||||
while (results.firstChild) results.removeChild(results.firstChild);
|
||||
try { history.replaceState(null, "", q ? "?q=" + encodeURIComponent(q) : location.pathname); } catch (e) { /* a file: page */ }
|
||||
if (!words.length) {
|
||||
results.hidden = true;
|
||||
index.hidden = false;
|
||||
status.textContent = "";
|
||||
return;
|
||||
}
|
||||
var hits = search(words);
|
||||
hits.slice(0, kMax).forEach(function (h) {
|
||||
var li = document.createElement("li"), a = document.createElement("a"), where = document.createElement("span");
|
||||
a.href = h.entry.href;
|
||||
a.className = "accent-link";
|
||||
a.textContent = h.entry.title;
|
||||
where.className = "s-where";
|
||||
where.textContent = h.entry.where;
|
||||
li.appendChild(a);
|
||||
li.appendChild(where);
|
||||
li.appendChild(snippet(h.entry, words));
|
||||
results.appendChild(li);
|
||||
});
|
||||
results.hidden = false;
|
||||
index.hidden = true;
|
||||
status.textContent = !hits.length ? "Nothing found for “" + q + "”. Every word has to be on the page."
|
||||
: (hits.length > kMax ? "The first " + kMax + " of " + hits.length : hits.length === 1 ? "1 place" : hits.length + " places") + " for “" + q + "”.";
|
||||
}
|
||||
|
||||
var timer = 0;
|
||||
input.addEventListener("input", function () {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(show, 80);
|
||||
});
|
||||
input.form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
show();
|
||||
});
|
||||
var asked = /[?&]q=([^&]*)/.exec(location.search);
|
||||
if (asked) {
|
||||
try { input.value = decodeURIComponent(asked[1].replace(/\+/g, " ")); } catch (e) { /* a bad escape: an empty box */ }
|
||||
}
|
||||
show();
|
||||
input.focus();
|
||||
});
|
||||
})();
|
||||
@@ -33,6 +33,7 @@
|
||||
<a href="/dev/">Developers</a>
|
||||
<a href="/downloads/">Downloads</a>
|
||||
<a href="/devlog/">Devlog</a>
|
||||
<a href="/search/">Search</a>
|
||||
<button class="link-btn js-only" id="theme-toggle" type="button">Light</button>
|
||||
<a class="btn btn-primary n-md" href="{{ config.extra.repo }}">Source</a>
|
||||
</nav>
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
|
||||
<div class="prose">{{ section.content | safe }}</div>
|
||||
|
||||
<form class="search-form search-mini" action="/search/" method="get" role="search">
|
||||
<label class="sr" for="q">Search the documentation</label>
|
||||
<input id="q" name="q" type="search" autocomplete="off" spellcheck="false" placeholder="Search the documentation">
|
||||
<button class="btn n-md" type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
<div class="cards">
|
||||
{% for path in section.subsections %}
|
||||
{% set sub = get_section(path=path) %}
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
|
||||
<div class="prose">{{ section.content | safe }}</div>
|
||||
|
||||
<form class="search-form search-mini" action="/search/" method="get" role="search">
|
||||
<label class="sr" for="q">Search the documentation</label>
|
||||
<input id="q" name="q" type="search" autocomplete="off" spellcheck="false" placeholder="Search the documentation">
|
||||
<button class="btn n-md" type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
<div class="cards">
|
||||
{% for p in section.pages %}
|
||||
<article class="card n-lg">
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{# One entry of the search page for each part of a page: what comes before its first heading,
|
||||
then each "## heading" with what follows it. The text is the page's own, tags taken out. #}
|
||||
{% macro entries(page, group) %}
|
||||
{% set parts = page.content | split(pat='<h2 id="') %}
|
||||
{% for part in parts %}
|
||||
{% if loop.first %}
|
||||
<li class="s-entry" data-group="{{ group }}"><a href="{{ page.path | safe }}">{{ page.title }}</a><span class="s-where">{{ group }}</span><p class="s-text">{{ page.description }} {{ part | striptags | trim | safe }}</p></li>
|
||||
{% else %}
|
||||
{% set anchor = part | split(pat='"') | first %}
|
||||
{% set head = part | split(pat="</h2>") | first %}
|
||||
{% set heading = head | split(pat='">') | slice(start=1) | join(sep='">') | striptags | trim %}
|
||||
{% set body = part | split(pat="</h2>") | slice(start=1) | join(sep="</h2>") | striptags | trim %}
|
||||
<li class="s-entry s-part" data-group="{{ group }}"><a href="{{ page.path | safe }}#{{ anchor }}">{{ heading | safe }}</a><span class="s-where">{{ group }} · {{ page.title }}</span><p class="s-text">{{ body | safe }}</p></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro entries %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/search.html" as search %}
|
||||
{% block title %}{{ page.title }}: roro9stack{% endblock %}
|
||||
{% block description %}{{ page.description }}{% endblock %}
|
||||
{% block head %}<script src="/js/search.js" defer></script>{% endblock %}
|
||||
{% block main %}
|
||||
{# The whole index is in this page (issue #60): nothing is fetched, and without JavaScript it is
|
||||
still a list of every page and heading of the documentation. js/search.js filters it. #}
|
||||
<div class="wrap page">
|
||||
<header>
|
||||
<span class="eyebrow">Documentation</span>
|
||||
<h1>{{ page.title }}</h1>
|
||||
<p class="lead">{{ page.description }}</p>
|
||||
</header>
|
||||
|
||||
<form class="search-form" action="/search/" method="get" role="search">
|
||||
<label for="q">Search the guide, the how-tos, the FAQ and the developer docs</label>
|
||||
<input id="q" name="q" type="search" autocomplete="off" spellcheck="false" placeholder="Probation, Safe Mode, rm -r, ...">
|
||||
<button class="btn btn-primary n-md" type="submit">Search</button>
|
||||
</form>
|
||||
<p class="s-status muted" id="s-status" role="status" aria-live="polite"></p>
|
||||
<ol class="s-results" id="s-results" hidden></ol>
|
||||
|
||||
<div class="s-index" id="s-index">
|
||||
<p class="muted s-nojs">Every page of the documentation and its headings. With JavaScript on, the box above searches their text.</p>
|
||||
{% set guide = get_section(path="guide/_index.md") %}
|
||||
<section><h2>{{ guide.title }}</h2><ul>
|
||||
{% for p in guide.pages %}{{ search::entries(page=p, group="Guide") }}{% endfor %}
|
||||
</ul></section>
|
||||
{% set howto = get_section(path="howto/_index.md") %}
|
||||
<section><h2>{{ howto.title }}</h2><ul>
|
||||
{% for p in howto.pages %}{{ search::entries(page=p, group="How-to") }}{% endfor %}
|
||||
</ul></section>
|
||||
<section><h2>Questions and answers</h2><ul>
|
||||
{{ search::entries(page=get_page(path="faq.md"), group="FAQ") }}
|
||||
</ul></section>
|
||||
{% set dev = get_section(path="dev/_index.md") %}
|
||||
{% for path in dev.subsections %}
|
||||
{% set sub = get_section(path=path) %}
|
||||
<section><h2>Developers: {{ sub.title }}</h2><ul>
|
||||
{% for p in sub.pages %}{{ search::entries(page=p, group=sub.extra.search | default(value=sub.title)) }}{% endfor %}
|
||||
</ul></section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -162,11 +162,15 @@ void FileViewer::open(const std::string& path, uint32_t size) {
|
||||
file_ = std::make_shared<fs::File>();
|
||||
std::string name = files::baseName(path);
|
||||
files::FileKind kind = files::kindOf(name);
|
||||
if (kind == files::FileKind::Unknown) { // what do its first bytes look like?
|
||||
files::ImageKind picture = files::imageKindOfName(name);
|
||||
if (kind == files::FileKind::Unknown && picture == files::ImageKind::None) { // what do its first bytes look like?
|
||||
uint8_t head[256];
|
||||
size_t n = readAt(0, head, sizeof head);
|
||||
picture = files::imageKindOfBytes(head, n);
|
||||
kind = files::looksLikeText(head, n) ? files::FileKind::Text : files::FileKind::Unknown;
|
||||
}
|
||||
std::string notShown;
|
||||
if (picture != files::ImageKind::None) notShown = image_.open(path, size);
|
||||
switch (kind) {
|
||||
case files::FileKind::Text: base_ = Mode::Text; break;
|
||||
case files::FileKind::Gpx: base_ = Mode::Gpx; break;
|
||||
@@ -174,11 +178,13 @@ void FileViewer::open(const std::string& path, uint32_t size) {
|
||||
case files::FileKind::Ota: base_ = Mode::Ota; break;
|
||||
default: base_ = Mode::Hex; break;
|
||||
}
|
||||
if (picture != files::ImageKind::None && notShown.empty()) base_ = Mode::Image;
|
||||
pager_.reset(new files::TextPager([this](uint32_t offset, uint8_t* into, size_t len) { return readAt(offset, into, len); }, size_,
|
||||
kCols, kRows));
|
||||
if (files::opensAtEnd(name)) pager_->toEnd();
|
||||
hexTop_ = 0;
|
||||
show(base_);
|
||||
if (!notShown.empty()) say(notShown); // a picture that can't be shown: its bytes, and why
|
||||
if (base_ == Mode::Gpx || base_ == Mode::Pcap || base_ == Mode::Ota) startScan(base_);
|
||||
}
|
||||
|
||||
@@ -203,6 +209,7 @@ void FileViewer::close() {
|
||||
});
|
||||
}
|
||||
file_.reset();
|
||||
image_.close();
|
||||
pager_.reset();
|
||||
confirm_.reset();
|
||||
message_.clear();
|
||||
@@ -214,11 +221,13 @@ void FileViewer::close() {
|
||||
|
||||
void FileViewer::show(Mode mode) {
|
||||
mode_ = mode;
|
||||
if (mode == Mode::Image) image_.lost(); // another view was drawn where it was
|
||||
stale_ = true;
|
||||
rowsFrom_ = -1;
|
||||
}
|
||||
|
||||
void FileViewer::say(const std::string& text) {
|
||||
if (mode_ == Mode::Image) return image_.say(text);
|
||||
message_ = text;
|
||||
messageMs_ = millis();
|
||||
}
|
||||
@@ -275,6 +284,7 @@ void FileViewer::help(std::vector<KeyHelp>& out) const {
|
||||
case Mode::Packet: keys::add(out, keys::kViewerPacket); break;
|
||||
case Mode::Gpx: keys::add(out, keys::kViewerGpx); break;
|
||||
case Mode::Ota: keys::add(out, keys::kViewerOta); break;
|
||||
case Mode::Image: image_.help(out); break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +309,7 @@ bool FileViewer::onKey(const KeyEvent& e) {
|
||||
else show(base_ == Mode::Hex || base_ == Mode::Gpx ? Mode::Text : Mode::Hex);
|
||||
return true;
|
||||
}
|
||||
if (mode_ == Mode::Image) return image_.onKey(e), true;
|
||||
uint32_t ch = e.key == Key::Char ? e.ch : 0;
|
||||
switch (mode_) {
|
||||
case Mode::Text:
|
||||
@@ -332,7 +343,8 @@ bool FileViewer::onKey(const KeyEvent& e) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FileViewer::update(uint32_t) {
|
||||
bool FileViewer::update(uint32_t nowMs) {
|
||||
if (mode_ == Mode::Image) return image_.update(nowMs);
|
||||
if (!message_.empty() && millis() - messageMs_ >= 4000) {
|
||||
message_.clear();
|
||||
return true;
|
||||
@@ -389,6 +401,7 @@ void FileViewer::refresh() {
|
||||
}
|
||||
|
||||
void FileViewer::draw(Canvas& c) {
|
||||
if (mode_ == Mode::Image) return image_.draw(c);
|
||||
const auto& area = theme::kContent;
|
||||
refresh();
|
||||
c.setTextDatum(top_left);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "apps/image_pane.h"
|
||||
#include "dialog_model.h"
|
||||
#include "key_help.h"
|
||||
#include "key_event.h"
|
||||
@@ -23,7 +24,7 @@ namespace roro {
|
||||
// genuine. Tab switches to the text or the hex of the same file. Nothing here changes a file.
|
||||
class FileViewer {
|
||||
public:
|
||||
FileViewer(StorageService& storage, UpdateService& update) : storage_(storage), update_(update) {}
|
||||
FileViewer(StorageService& storage, UpdateService& update) : storage_(storage), update_(update), image_(storage) {}
|
||||
|
||||
void open(const std::string& path, uint32_t size);
|
||||
void close();
|
||||
@@ -37,9 +38,12 @@ class FileViewer {
|
||||
const std::string& path() const { return path_; }
|
||||
uint32_t size() const { return size_; }
|
||||
void say(const std::string& text);
|
||||
// A picture is drawn once and kept on the screen (App::retainsContent).
|
||||
bool retains() const { return mode_ == Mode::Image; }
|
||||
void lost() { image_.lost(); }
|
||||
|
||||
private:
|
||||
enum class Mode { Text, Hex, Gpx, Pcap, Packet, Ota };
|
||||
enum class Mode { Text, Hex, Gpx, Pcap, Packet, Ota, Image };
|
||||
static constexpr int kRows = 8;
|
||||
static constexpr int kCols = 38;
|
||||
struct Scan; // what a storage job reads through a whole file for: shared with that job
|
||||
@@ -54,6 +58,7 @@ class FileViewer {
|
||||
|
||||
StorageService& storage_;
|
||||
UpdateService& update_;
|
||||
ImagePane image_;
|
||||
std::string path_;
|
||||
uint32_t size_ = 0;
|
||||
Mode mode_ = Mode::Text, base_ = Mode::Text;
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
#include "image_pane.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SD.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <new>
|
||||
|
||||
#include <lgfx/utility/lgfx_tjpgd.h>
|
||||
|
||||
#include "app_keys.h"
|
||||
#include "file_names.h"
|
||||
#include "platform/console.h"
|
||||
#include "png_reader.h"
|
||||
#include "ui/fonts.h"
|
||||
#include "ui/theme.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t kNoteMs = 3000;
|
||||
constexpr uint32_t kPushMs = 250; // how often the screen shows how far the decoding is
|
||||
constexpr int kNoteHeight = 11;
|
||||
constexpr size_t kJpegPool = 3900; // what the library gives its own JPEG decoder
|
||||
} // namespace
|
||||
|
||||
struct ImagePane::Job {
|
||||
std::string path, why;
|
||||
files::ImageInfo info;
|
||||
files::ImageFrame frame;
|
||||
files::ImageMap map;
|
||||
uint32_t size = 0, shotAt = 0, tookMs = 0;
|
||||
uint8_t* screen = nullptr; // the screen's buffer: one byte a pixel, RRRGGGBB
|
||||
int stride = 0;
|
||||
std::atomic<bool> stop{false}, done{false};
|
||||
bool enough = false; // the rest of the file is under the view: the decoder is told to stop
|
||||
File* file = nullptr;
|
||||
uint32_t sinceRest = 0;
|
||||
|
||||
void put(int sx, int sy, uint8_t r, uint8_t g, uint8_t b) {
|
||||
int tx, ty;
|
||||
if (map.at(sx, sy, tx, ty)) screen[ty * stride + tx] = files::rgb332Dithered(r, g, b, tx, ty);
|
||||
}
|
||||
// A long decoding must leave the processor to others now and then (the idle task is watched).
|
||||
void rest(uint32_t bytes) {
|
||||
sinceRest += bytes;
|
||||
if (sinceRest < 16 * 1024) return;
|
||||
sinceRest = 0;
|
||||
vTaskDelay(1);
|
||||
}
|
||||
size_t readAt(uint32_t at, uint8_t* into, size_t len) {
|
||||
if (stop || !file->seek(at)) return 0;
|
||||
int n = file->read(into, len);
|
||||
rest(static_cast<uint32_t>(len));
|
||||
return n > 0 ? static_cast<size_t>(n) : 0;
|
||||
}
|
||||
void run();
|
||||
};
|
||||
|
||||
namespace {
|
||||
// The library's JPEG decoder reads the file from its start on; a null buffer means "skip".
|
||||
// Nothing more to read is how a decoding is told to stop.
|
||||
uint32_t readNext(void* job, uint8_t* into, uint32_t len) {
|
||||
auto& j = *static_cast<ImagePane::Job*>(job);
|
||||
if (j.stop || j.enough) return 0;
|
||||
File& f = *j.file;
|
||||
j.rest(len);
|
||||
if (!into) return f.seek(f.position() + len) ? len : 0;
|
||||
int n = f.read(into, len);
|
||||
return n > 0 ? static_cast<uint32_t>(n) : 0;
|
||||
}
|
||||
|
||||
// A block of a JPEG: its pixels row by row, three bytes each.
|
||||
uint32_t jpegBlock(void* job, void* bitmap, JRECT* rect) {
|
||||
auto& j = *static_cast<ImagePane::Job*>(job);
|
||||
const uint8_t* p = static_cast<const uint8_t*>(bitmap);
|
||||
if (j.map.below(static_cast<int>(rect->top))) return j.enough = true, 0;
|
||||
for (uint32_t y = rect->top; y <= rect->bottom; y++)
|
||||
for (uint32_t x = rect->left; x <= rect->right; x++, p += 3) j.put(static_cast<int>(x), static_cast<int>(y), p[0], p[1], p[2]);
|
||||
return j.stop ? 0 : 1;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// On the storage task.
|
||||
void ImagePane::Job::run() {
|
||||
uint32_t started = millis();
|
||||
File f = SD.open(path.c_str(), FILE_READ);
|
||||
if (!f) {
|
||||
why = "The card refused to open it";
|
||||
done = true;
|
||||
return;
|
||||
}
|
||||
file = &f;
|
||||
files::ImageRead read = [this](uint32_t at, uint8_t* into, size_t len) { return readAt(at, into, len); };
|
||||
files::ImagePixels pixels = [this](int x, int y, int count, const uint8_t* rgb) {
|
||||
for (int i = 0; i < count; i++, rgb += 3) put(x + i, y, rgb[0], rgb[1], rgb[2]);
|
||||
};
|
||||
switch (info.kind) {
|
||||
case files::ImageKind::Png:
|
||||
if (shotAt) { // one of ours: each byte is already a colour of the screen
|
||||
std::unique_ptr<uint8_t[]> row(new (std::nothrow) uint8_t[info.width]);
|
||||
if (!row) {
|
||||
why = "Not enough memory";
|
||||
break;
|
||||
}
|
||||
for (int y = 0; y < info.height && why.empty() && !stop; y++) {
|
||||
if (!map.rowUsed(y)) continue;
|
||||
size_t w = static_cast<size_t>(info.width);
|
||||
if (readAt(shotAt + static_cast<uint32_t>(y) * (info.width + 1), row.get(), w) != w) why = "The card refused to read it";
|
||||
int tx, ty;
|
||||
for (int x = 0; x < info.width; x++)
|
||||
if (map.at(x, y, tx, ty)) screen[ty * stride + tx] = row[x];
|
||||
}
|
||||
break;
|
||||
}
|
||||
why = files::readPng(read, size, pixels, [this](int y) { return map.rowUsed(y); }, [this](int y) { return map.below(y); });
|
||||
break;
|
||||
case files::ImageKind::Jpeg: {
|
||||
std::unique_ptr<lgfxJdec> jpeg(new (std::nothrow) lgfxJdec);
|
||||
std::unique_ptr<uint8_t[]> pool(new (std::nothrow) uint8_t[kJpegPool]);
|
||||
if (!jpeg || !pool) {
|
||||
why = "Not enough memory";
|
||||
break;
|
||||
}
|
||||
int shrink = frame.jpegShrink();
|
||||
map = frame.map(shrink);
|
||||
JRESULT r = lgfx_jd_prepare(jpeg.get(), readNext, pool.get(), kJpegPool, this);
|
||||
if (r == JDR_OK) r = lgfx_jd_decomp(jpeg.get(), jpegBlock, static_cast<uint_fast8_t>(shrink));
|
||||
if (r == JDR_FMT3) why = "This kind of JPEG can't be shown";
|
||||
else if (r == JDR_MEM1 || r == JDR_MEM2) why = "This JPEG is too complex to show";
|
||||
else if (r != JDR_OK && !enough) why = "This JPEG is damaged";
|
||||
break;
|
||||
}
|
||||
case files::ImageKind::Bmp:
|
||||
why = files::readBmp(read, size, pixels, [this](int y) { return map.rowUsed(y); });
|
||||
break;
|
||||
case files::ImageKind::Gif: why = files::readGif(read, size, pixels); break;
|
||||
default: why = "Not a picture this can show"; break;
|
||||
}
|
||||
f.close();
|
||||
file = nullptr;
|
||||
tookMs = millis() - started;
|
||||
if (stop) why.clear();
|
||||
else
|
||||
console.printf("image: %s, %d x %d %s, %s in %u ms\n", path.c_str(), info.width, info.height, files::imageKindName(info.kind),
|
||||
why.empty() ? (frame.actual() ? "its own size" : "fitted") : why.c_str(), (unsigned)tookMs);
|
||||
done = true;
|
||||
}
|
||||
|
||||
std::string ImagePane::open(const std::string& path, uint32_t size) {
|
||||
close();
|
||||
std::string why;
|
||||
files::ImageInfo info;
|
||||
uint32_t shotAt = 0;
|
||||
bool ran = storage_.runAndWait([&]() {
|
||||
File f = SD.open(path.c_str(), FILE_READ);
|
||||
if (!f) {
|
||||
why = "The card refused to open it";
|
||||
return;
|
||||
}
|
||||
files::ImageRead read = [&f](uint32_t at, uint8_t* into, size_t len) -> size_t {
|
||||
if (!f.seek(at)) return 0;
|
||||
int n = f.read(into, len);
|
||||
return n > 0 ? static_cast<size_t>(n) : 0;
|
||||
};
|
||||
why = files::imageInfo(read, size, info);
|
||||
if (why.empty() && info.kind == files::ImageKind::Png) shotAt = files::screenshotPixelsAt(read, size, info.width, info.height);
|
||||
f.close();
|
||||
});
|
||||
if (!ran) why = "No SD card";
|
||||
if (!why.empty()) return why;
|
||||
path_ = path;
|
||||
size_ = size;
|
||||
info_ = info;
|
||||
shotAt_ = shotAt;
|
||||
const auto& area = theme::kContent;
|
||||
frame_ = files::ImageFrame(info.width, info.height, area.x, area.y, area.w, area.h);
|
||||
phase_ = Phase::Wanted;
|
||||
told_ = noteDrawn_ = false;
|
||||
problem_.clear();
|
||||
note_.clear();
|
||||
return "";
|
||||
}
|
||||
|
||||
void ImagePane::cancel() {
|
||||
if (job_ && !job_->done) {
|
||||
job_->stop = true;
|
||||
storage_.runAndWait([]() {}); // behind the decoding in the queue: back when it has ended
|
||||
}
|
||||
job_.reset();
|
||||
}
|
||||
|
||||
void ImagePane::close() {
|
||||
cancel();
|
||||
path_.clear();
|
||||
problem_.clear();
|
||||
note_.clear();
|
||||
info_ = files::ImageInfo();
|
||||
phase_ = Phase::Wanted;
|
||||
std::vector<uint8_t>().swap(under_);
|
||||
}
|
||||
|
||||
void ImagePane::lost() {
|
||||
cancel();
|
||||
phase_ = Phase::Wanted;
|
||||
noteDrawn_ = false;
|
||||
}
|
||||
|
||||
std::string ImagePane::describe() const {
|
||||
std::string s = std::to_string(info_.width) + " x " + std::to_string(info_.height) + " " + files::imageKindName(info_.kind);
|
||||
if (frame_.bigger()) s += frame_.actual() ? ", its own size" : ", at " + std::to_string(frame_.percent()) + " %";
|
||||
return s;
|
||||
}
|
||||
|
||||
void ImagePane::say(const std::string& text) {
|
||||
hideNote(canvas_);
|
||||
note_ = text;
|
||||
noteMs_ = millis();
|
||||
}
|
||||
|
||||
void ImagePane::help(std::vector<KeyHelp>& out) const { keys::add(out, keys::kViewerImage); }
|
||||
|
||||
bool ImagePane::onKey(const KeyEvent& e) {
|
||||
if (path_.empty()) return false;
|
||||
bool changed = false;
|
||||
switch (e.key) {
|
||||
case Key::Select:
|
||||
if (!frame_.bigger()) return true;
|
||||
cancel(); // before the frame it is drawing into changes
|
||||
frame_.toggle();
|
||||
told_ = false;
|
||||
changed = true;
|
||||
break;
|
||||
case Key::Up:
|
||||
case Key::Down:
|
||||
case Key::Left:
|
||||
case Key::Right: {
|
||||
if (!frame_.actual()) return true;
|
||||
cancel();
|
||||
int dx = e.key == Key::Left ? -1 : e.key == Key::Right ? 1 : 0, dy = e.key == Key::Up ? -1 : e.key == Key::Down ? 1 : 0;
|
||||
changed = frame_.pan(dx, dy);
|
||||
if (!changed && phase_ == Phase::Decoding) changed = true; // it was stopped: start it again
|
||||
break;
|
||||
}
|
||||
case Key::Char:
|
||||
if (e.ch != 'i' && e.ch != 'I') return false;
|
||||
if (phase_ == Phase::Shown) say(describe());
|
||||
return true;
|
||||
default: return false;
|
||||
}
|
||||
if (changed) {
|
||||
phase_ = Phase::Wanted;
|
||||
note_.clear();
|
||||
noteDrawn_ = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The strip the note was written over goes back as it was: no decoding for that.
|
||||
void ImagePane::hideNote(Canvas* c) {
|
||||
if (noteDrawn_ && c && phase_ == Phase::Shown && under_.size() == static_cast<size_t>(c->width()) * kNoteHeight) {
|
||||
const auto& area = theme::kContent;
|
||||
uint8_t* screen = static_cast<uint8_t*>(c->getBuffer());
|
||||
std::memcpy(screen + (area.y + area.h - kNoteHeight) * c->width(), under_.data(), under_.size());
|
||||
}
|
||||
noteDrawn_ = false;
|
||||
note_.clear();
|
||||
}
|
||||
|
||||
bool ImagePane::update(uint32_t) {
|
||||
if (path_.empty()) return false;
|
||||
uint32_t now = millis();
|
||||
if (phase_ == Phase::Wanted) return true;
|
||||
if (phase_ == Phase::Decoding) {
|
||||
if (job_ && job_->done) return true;
|
||||
if (now - pushedMs_ < kPushMs) return false;
|
||||
pushedMs_ = now;
|
||||
return true; // what has arrived so far
|
||||
}
|
||||
if (!note_.empty() && now - noteMs_ >= kNoteMs) {
|
||||
hideNote(canvas_);
|
||||
return true;
|
||||
}
|
||||
return !note_.empty() && !noteDrawn_;
|
||||
}
|
||||
|
||||
void ImagePane::start(Canvas& c) {
|
||||
cancel();
|
||||
job_ = std::make_shared<Job>();
|
||||
job_->path = path_;
|
||||
job_->info = info_;
|
||||
job_->frame = frame_;
|
||||
job_->map = frame_.map();
|
||||
job_->size = size_;
|
||||
job_->shotAt = shotAt_;
|
||||
job_->screen = static_cast<uint8_t*>(c.getBuffer());
|
||||
job_->stride = c.width();
|
||||
auto job = job_;
|
||||
storage_.runJob([job]() { job->run(); });
|
||||
phase_ = Phase::Decoding;
|
||||
pushedMs_ = millis();
|
||||
}
|
||||
|
||||
void ImagePane::draw(Canvas& c) {
|
||||
if (path_.empty()) return;
|
||||
canvas_ = &c;
|
||||
const auto& area = theme::kContent;
|
||||
if (phase_ == Phase::Wanted) {
|
||||
c.fillRect(area.x, area.y, area.w, area.h, theme::kBackground);
|
||||
noteDrawn_ = false;
|
||||
problem_.clear();
|
||||
start(c);
|
||||
return;
|
||||
}
|
||||
if (phase_ == Phase::Decoding) {
|
||||
if (!job_ || !job_->done) return; // the picture is arriving in the buffer by itself
|
||||
problem_ = job_->why;
|
||||
job_.reset();
|
||||
phase_ = Phase::Shown;
|
||||
if (!problem_.empty()) {
|
||||
c.fillRect(area.x, area.y, area.w, area.h, theme::kBackground);
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.setTextDatum(middle_center);
|
||||
c.drawString(problem_.c_str(), area.x + area.w / 2, area.y + area.h / 2);
|
||||
c.setTextDatum(top_left);
|
||||
} else if (!told_) {
|
||||
told_ = true;
|
||||
note_ = describe();
|
||||
noteMs_ = millis();
|
||||
}
|
||||
}
|
||||
if (!note_.empty() && !noteDrawn_) {
|
||||
int top = area.y + area.h - kNoteHeight;
|
||||
uint8_t* screen = static_cast<uint8_t*>(c.getBuffer());
|
||||
under_.assign(screen + top * c.width(), screen + (top + kNoteHeight) * c.width());
|
||||
c.fillRect(area.x, top, area.w, kNoteHeight, theme::kBackground);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kText);
|
||||
c.setTextDatum(top_left);
|
||||
c.drawString(note_.c_str(), area.x + 4, top + 2);
|
||||
noteDrawn_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "image_file.h"
|
||||
#include "key_event.h"
|
||||
#include "key_help.h"
|
||||
#include "services/storage_service.h"
|
||||
#include "ui/canvas.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// A picture from the card, for the Storage App's viewer (issue #45, F1 Q233-Q242): PNG, JPEG, BMP
|
||||
// and the first picture of a GIF (only JPEG needs a decoder that isn't ours). It is decoded once, straight into the screen's own buffer, and
|
||||
// left there (App::retainsContent): there is no copy of it in memory. It is decoded again only
|
||||
// when something else was drawn over it, or when it is zoomed or moved.
|
||||
//
|
||||
// The decoding runs on the storage task while the main loop goes on: a photograph takes seconds,
|
||||
// and the picture appears as it comes. Anything that needs the screen back stops it first.
|
||||
class ImagePane {
|
||||
public:
|
||||
explicit ImagePane(StorageService& storage) : storage_(storage) {}
|
||||
|
||||
std::string open(const std::string& path, uint32_t size); // "" or why it can't be shown
|
||||
void close();
|
||||
bool onKey(const KeyEvent& e); // true: the key was the picture's
|
||||
void help(std::vector<KeyHelp>& out) const;
|
||||
bool update(uint32_t nowMs); // true: draw again
|
||||
void draw(Canvas& c);
|
||||
void lost(); // the screen no longer holds it
|
||||
void say(const std::string& text);
|
||||
|
||||
struct Job; // one decoding: shared with the storage task, which may outlive the view of it
|
||||
|
||||
private:
|
||||
enum class Phase { Wanted, Decoding, Shown };
|
||||
|
||||
std::string describe() const;
|
||||
void start(Canvas& c);
|
||||
void cancel(); // returns once the storage task has let go of the screen
|
||||
void hideNote(Canvas* c);
|
||||
|
||||
StorageService& storage_;
|
||||
std::string path_, problem_, note_;
|
||||
uint32_t size_ = 0, noteMs_ = 0, shotAt_ = 0, pushedMs_ = 0;
|
||||
files::ImageInfo info_;
|
||||
files::ImageFrame frame_;
|
||||
Phase phase_ = Phase::Wanted;
|
||||
std::shared_ptr<Job> job_;
|
||||
Canvas* canvas_ = nullptr;
|
||||
bool told_ = false, noteDrawn_ = false;
|
||||
std::vector<uint8_t> under_; // what the note was written over
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -9,24 +9,108 @@
|
||||
#include "cleanup_plan.h"
|
||||
#include "file_list.h"
|
||||
#include "file_names.h"
|
||||
#include "platform/console.h"
|
||||
#include "ui/fonts.h"
|
||||
#include "ui/theme.h"
|
||||
#include "ui/widgets.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
using notes::NoteDocument;
|
||||
using notes::NoteText;
|
||||
|
||||
std::function<void(const std::string&, int)> NoteEditor::onProgress;
|
||||
|
||||
// The SD card for a NoteDocument. Every call is made on the storage task. The file being read and
|
||||
// the file being appended to stay open between calls (a window is read in a few pieces, a rewrite
|
||||
// appends some five hundred blocks to the megabyte), until done().
|
||||
class NoteEditor::Card : public notes::NoteCard {
|
||||
public:
|
||||
explicit Card(StorageService& storage) : storage_(storage) {}
|
||||
|
||||
bool size(const std::string& path, uint32_t& size) override {
|
||||
close(path);
|
||||
File f = SD.open(path.c_str(), FILE_READ);
|
||||
if (!f || f.isDirectory()) return false;
|
||||
size = static_cast<uint32_t>(f.size());
|
||||
f.close();
|
||||
return true;
|
||||
}
|
||||
size_t read(const std::string& path, uint32_t at, uint8_t* into, size_t len) override {
|
||||
if (appendPath_ == path) closeAppend(); // what was appended has to be there to read
|
||||
if (readPath_ != path || !read_) {
|
||||
closeRead();
|
||||
read_ = SD.open(path.c_str(), FILE_READ);
|
||||
if (!read_) return 0;
|
||||
readPath_ = path;
|
||||
}
|
||||
if (!read_.seek(at)) return 0;
|
||||
int n = read_.read(into, len);
|
||||
return n > 0 ? static_cast<size_t>(n) : 0;
|
||||
}
|
||||
bool create(const std::string& path) override {
|
||||
close(path);
|
||||
File f = SD.open(path.c_str(), FILE_WRITE);
|
||||
if (!f) return false;
|
||||
f.close();
|
||||
return true;
|
||||
}
|
||||
bool append(const std::string& path, const uint8_t* data, size_t len) override {
|
||||
if (readPath_ == path) closeRead();
|
||||
if (appendPath_ != path || !append_) {
|
||||
closeAppend();
|
||||
append_ = SD.open(path.c_str(), FILE_APPEND);
|
||||
if (!append_) return false;
|
||||
appendPath_ = path;
|
||||
}
|
||||
return append_.write(data, len) == len;
|
||||
}
|
||||
bool remove(const std::string& path) override {
|
||||
close(path);
|
||||
return SD.remove(path.c_str());
|
||||
}
|
||||
bool rename(const std::string& from, const std::string& to) override {
|
||||
done();
|
||||
return SD.rename(from.c_str(), to.c_str());
|
||||
}
|
||||
uint64_t freeBytes() override {
|
||||
StorageState s = storage_.state();
|
||||
return s.totalBytes > s.usedBytes ? s.totalBytes - s.usedBytes : 0;
|
||||
}
|
||||
void done() override {
|
||||
closeRead();
|
||||
closeAppend();
|
||||
}
|
||||
|
||||
private:
|
||||
void close(const std::string& path) {
|
||||
if (readPath_ == path) closeRead();
|
||||
if (appendPath_ == path) closeAppend();
|
||||
}
|
||||
void closeRead() {
|
||||
if (read_) read_.close();
|
||||
readPath_.clear();
|
||||
}
|
||||
void closeAppend() {
|
||||
if (append_) append_.close();
|
||||
appendPath_.clear();
|
||||
}
|
||||
|
||||
StorageService& storage_;
|
||||
File read_, append_;
|
||||
std::string readPath_, appendPath_;
|
||||
};
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t kMessageMs = 4000;
|
||||
|
||||
// One block the size of a full note, and something left: without it, nothing is opened. Free
|
||||
// One block the size of the window, and something left: without it, nothing is opened. Free
|
||||
// memory in total isn't the measure: with IRC connected the largest free block is about 31 KB.
|
||||
constexpr size_t kRoomWanted = NoteText::kMaxBytes + 8 * 1024;
|
||||
const char* const kNoRoom = "Not enough memory to edit: close IRC or a Gemini page";
|
||||
|
||||
// On the storage task. Reads a file of up to `limit` bytes into a string that has that capacity
|
||||
// already; false if it can't be read whole.
|
||||
// On the storage task. Reads a file of up to `limit` bytes into a string; false if it can't be
|
||||
// read whole.
|
||||
bool readWhole(const std::string& path, std::string& into, size_t limit) {
|
||||
File f = SD.open(path.c_str(), FILE_READ);
|
||||
if (!f) return false;
|
||||
@@ -51,42 +135,43 @@ void NoteEditor::say(const std::string& text) {
|
||||
std::string NoteEditor::open(const std::string& path) {
|
||||
close();
|
||||
if (ESP.getMaxAllocHeap() < kRoomWanted) return kNoRoom;
|
||||
std::string body, left, why;
|
||||
body.reserve(NoteText::kMaxBytes); // the note's own buffer from here on: read into, then handed over
|
||||
card_ = std::make_shared<Card>(storage_);
|
||||
doc_.reset(new NoteDocument(*card_, kCols, kRows));
|
||||
std::string left, why, told;
|
||||
bool ran = storage_.runAndWait([&]() {
|
||||
File f = SD.open(path.c_str(), FILE_READ);
|
||||
if (!f) {
|
||||
why = "The card refused to open it";
|
||||
return;
|
||||
}
|
||||
size_t size = f.size();
|
||||
f.close();
|
||||
if (size > NoteText::kMaxBytes) why = "Too big to edit: 16 KB at most";
|
||||
else if (!readWhole(path, body, NoteText::kMaxBytes)) why = "The card refused to read it";
|
||||
if (!why.empty()) return;
|
||||
// A save that never finished: its temporary file is offered back (Q143), if there's the
|
||||
// memory to look at it now. If not, it stays for the next time.
|
||||
// A save of a small note that never finished: its temporary file is offered back (Q143),
|
||||
// if there's the memory to look at it now. A bigger note's unfinished saves are in its
|
||||
// side file, and the document picks them up by itself.
|
||||
std::string tmp = path + ".tmp";
|
||||
File t = SD.open(tmp.c_str(), FILE_READ);
|
||||
if (!t) return;
|
||||
size_t tmpSize = t.size();
|
||||
t.close();
|
||||
if (tmpSize > 0 && tmpSize <= NoteText::kMaxBytes && ESP.getMaxAllocHeap() < tmpSize + 8 * 1024) return;
|
||||
left.reserve(tmpSize <= NoteText::kMaxBytes ? tmpSize : 0);
|
||||
if (!readWhole(tmp, left, NoteText::kMaxBytes) || left == body || left.empty()) {
|
||||
size_t tmpSize = t ? t.size() : 0;
|
||||
bool hasTmp = static_cast<bool>(t);
|
||||
if (t) t.close();
|
||||
bool hasSide = SD.exists((path + ".edit").c_str());
|
||||
if (hasTmp && !hasSide && tmpSize > 0 && tmpSize <= NoteText::kMaxBytes && ESP.getMaxAllocHeap() >= kRoomWanted + tmpSize) {
|
||||
left.reserve(tmpSize);
|
||||
if (!readWhole(tmp, left, NoteText::kMaxBytes)) std::string().swap(left);
|
||||
}
|
||||
why = doc_->open(path, &told);
|
||||
if (!why.empty()) return;
|
||||
if (hasTmp && !hasSide && (left.empty() || doc_->windowed() || left == doc_->text().text())) {
|
||||
std::string().swap(left);
|
||||
SD.remove(tmp.c_str());
|
||||
}
|
||||
});
|
||||
if (!ran) return "No SD card";
|
||||
if (!why.empty()) return why;
|
||||
text_.reset(new NoteText(kCols, kRows, std::move(body)));
|
||||
if (!ran) why = "No SD card";
|
||||
if (!why.empty()) {
|
||||
doc_.reset();
|
||||
card_.reset();
|
||||
return why;
|
||||
}
|
||||
path_ = path;
|
||||
folder_ = files::parentOf(path);
|
||||
savedRevision_ = text_->revision();
|
||||
problem_.clear();
|
||||
message_.clear();
|
||||
givenUp_ = false;
|
||||
lastKeyMs_ = millis();
|
||||
if (!told.empty()) say(told);
|
||||
if (!left.empty()) {
|
||||
recovered_ = std::move(left);
|
||||
ask_ = Ask::Recover;
|
||||
@@ -98,36 +183,38 @@ std::string NoteEditor::open(const std::string& path) {
|
||||
std::string NoteEditor::openNew(const std::string& folder) {
|
||||
close();
|
||||
if (ESP.getMaxAllocHeap() < kRoomWanted) return kNoRoom;
|
||||
text_.reset(new NoteText(kCols, kRows));
|
||||
card_ = std::make_shared<Card>(storage_);
|
||||
doc_.reset(new NoteDocument(*card_, kCols, kRows));
|
||||
path_.clear();
|
||||
folder_ = folder;
|
||||
savedRevision_ = text_->revision();
|
||||
problem_.clear();
|
||||
message_.clear();
|
||||
givenUp_ = false;
|
||||
lastKeyMs_ = millis();
|
||||
return "";
|
||||
}
|
||||
|
||||
// Leaving rewrites the file, however long the note (Q225). Not when the device is powering off:
|
||||
// then a long note's edits go to its side file, which is quick, and are picked up the next time.
|
||||
void NoteEditor::close() {
|
||||
if (dirty()) save();
|
||||
text_.reset();
|
||||
if (doc_ && !givenUp_ && owed()) save(!power_.poweringOff());
|
||||
doc_.reset();
|
||||
card_.reset();
|
||||
dialog_.reset();
|
||||
ask_ = Ask::None;
|
||||
std::string().swap(recovered_);
|
||||
}
|
||||
|
||||
// On the main loop, waiting for the storage task: no second copy of the note is made, and at
|
||||
// 16 KB the wait is a fraction of a second, when nobody has typed for five.
|
||||
bool NoteEditor::save() {
|
||||
if (!text_) return true;
|
||||
// On the main loop, waiting for the storage task: no second copy of the text is made. A note of
|
||||
// up to 64 KB is rewritten in a fraction of a second, when nobody has typed for five; a longer
|
||||
// one takes a second for each 400 KB or so, and shows how far it is.
|
||||
bool NoteEditor::save(bool whole) {
|
||||
if (!doc_) return true;
|
||||
lastTryMs_ = millis();
|
||||
const std::string& body = text_->text();
|
||||
if (path_.empty() && body.empty()) { // a new note nothing was typed in: no file
|
||||
savedRevision_ = text_->revision();
|
||||
return true;
|
||||
}
|
||||
if (path_.empty() && doc_->size() == 0) return true; // a new note nothing was typed in: no file
|
||||
std::string path = path_, why;
|
||||
if (path.empty()) {
|
||||
bool fresh = path_.empty();
|
||||
if (fresh) {
|
||||
char stamp[20] = "new";
|
||||
int64_t now = clock_.utcNow();
|
||||
if (now >= 0) {
|
||||
@@ -137,10 +224,15 @@ bool NoteEditor::save() {
|
||||
std::snprintf(stamp, sizeof stamp, "%04d%02d%02d-%02d%02d", local.tm_year + 1900, local.tm_mon + 1, local.tm_mday, local.tm_hour,
|
||||
local.tm_min);
|
||||
}
|
||||
path = files::joinPath(folder_, notes::nameFromFirstLine(text_->firstLine(), stamp) + ".txt");
|
||||
path = files::joinPath(folder_, notes::nameFromFirstLine(doc_->text().firstLine(), stamp) + ".txt");
|
||||
}
|
||||
bool fresh = path_.empty();
|
||||
bool rewrite = whole || fresh || doc_->wantsRewrite();
|
||||
int percent = 0;
|
||||
bool ran = storage_.runAndWait([&]() {
|
||||
if (!rewrite) {
|
||||
doc_->journal(why);
|
||||
return;
|
||||
}
|
||||
if (!SD.exists(folder_.c_str()) && !SD.mkdir(folder_.c_str())) {
|
||||
why = "the card refused to make " + folder_;
|
||||
return;
|
||||
@@ -148,48 +240,59 @@ bool NoteEditor::save() {
|
||||
if (fresh) { // a name nothing has yet: "list (2).txt"
|
||||
std::string name = files::baseName(path);
|
||||
for (int n = 2; n < 100 && SD.exists(path.c_str()); n++) path = files::joinPath(folder_, files::copyName(name, n));
|
||||
doc_->setPath(path);
|
||||
}
|
||||
std::string tmp = path + ".tmp";
|
||||
File f = SD.open(tmp.c_str(), FILE_WRITE);
|
||||
if (!f) {
|
||||
why = "the card refused to open a file";
|
||||
return;
|
||||
}
|
||||
size_t wrote = body.empty() ? 0 : f.write(reinterpret_cast<const uint8_t*>(body.data()), body.size());
|
||||
f.close();
|
||||
File check = SD.open(tmp.c_str(), FILE_READ);
|
||||
bool whole = wrote == body.size() && check && check.size() == body.size();
|
||||
if (check) check.close();
|
||||
if (!whole) {
|
||||
SD.remove(tmp.c_str());
|
||||
why = "the card refused a write";
|
||||
return;
|
||||
}
|
||||
// FAT can't rename onto a file. Between these two lines only the temporary file exists:
|
||||
// the Notes list puts such a file back under its name.
|
||||
if (SD.exists(path.c_str())) SD.remove(path.c_str());
|
||||
if (!SD.rename(tmp.c_str(), path.c_str())) why = "the card refused to rename the file";
|
||||
if (doc_->rewriteStart(why)) percent = doc_->rewriteStep(why);
|
||||
if (fresh && !why.empty()) doc_->setPath("");
|
||||
});
|
||||
bool show = doc_->size() > NoteDocument::kWholeLimit;
|
||||
uint32_t started = millis();
|
||||
while (ran && rewrite && why.empty() && percent >= 0 && percent < 100) {
|
||||
if (show && onProgress) onProgress(files::baseName(path), percent);
|
||||
ran = storage_.runAndWait([&]() { percent = doc_->rewriteStep(why); });
|
||||
}
|
||||
if (!ran) why = "no SD card";
|
||||
if (!why.empty()) {
|
||||
if (problem_ != why) say("Not saved: " + why);
|
||||
problem_ = why;
|
||||
redraw_ = true;
|
||||
return false;
|
||||
}
|
||||
if (rewrite && show) console.printf("notes: rewrote %s, %u bytes in %.1f s\n", path.c_str(), (unsigned)doc_->size(), (millis() - started) / 1000.0);
|
||||
path_ = path;
|
||||
problem_.clear();
|
||||
savedRevision_ = text_->revision();
|
||||
redraw_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void NoteEditor::settle() {
|
||||
if (!doc_ || !doc_->wantsMove()) return;
|
||||
// A window that leaves memory needs a file to belong to: a new note is saved first.
|
||||
if (path_.empty() && !save(true)) return;
|
||||
std::string why;
|
||||
bool ran = storage_.runAndWait([&]() { doc_->move(why); });
|
||||
if (!ran) why = "no SD card";
|
||||
if (!why.empty() && problem_ != why) say("The card: " + why);
|
||||
if (!why.empty()) problem_ = why;
|
||||
}
|
||||
|
||||
bool NoteEditor::jump(bool toEnd) {
|
||||
std::string why;
|
||||
uint32_t to = toEnd ? doc_->size() : 0;
|
||||
bool ran = storage_.runAndWait([&]() { doc_->jump(to, why); });
|
||||
if (!ran) why = "no SD card";
|
||||
if (!why.empty()) say("The card: " + why);
|
||||
return why.empty();
|
||||
}
|
||||
|
||||
void NoteEditor::help(std::vector<KeyHelp>& out) const {
|
||||
if (dialog_) return keys::add(out, keys::kDialog);
|
||||
keys::add(out, keys::kNotesEditor);
|
||||
}
|
||||
|
||||
bool NoteEditor::onKey(const KeyEvent& e) {
|
||||
if (!text_) return false;
|
||||
if (!doc_) return false;
|
||||
NoteText* text_ = &doc_->text();
|
||||
redraw_ = true;
|
||||
if (dialog_) {
|
||||
dialog_->onKey(e);
|
||||
@@ -210,7 +313,7 @@ bool NoteEditor::onKey(const KeyEvent& e) {
|
||||
return true;
|
||||
}
|
||||
if (asked == Ask::LeaveUnsaved && result == 1) {
|
||||
savedRevision_ = text_->revision(); // given up on
|
||||
givenUp_ = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -225,37 +328,38 @@ bool NoteEditor::onKey(const KeyEvent& e) {
|
||||
else if (lower == 'e') text_->lineEnd();
|
||||
break;
|
||||
}
|
||||
if (!text_->insert(e.ch)) say("This note is full: 16 KB");
|
||||
if (!text_->insert(e.ch)) say("Can't type: " + problem_);
|
||||
break;
|
||||
}
|
||||
case Key::Select:
|
||||
if (!text_->insert('\n')) say("This note is full: 16 KB");
|
||||
if (!text_->insert('\n')) say("Can't type: " + problem_);
|
||||
break;
|
||||
case Key::Tab:
|
||||
if (!text_->insertText(" ")) say("This note is full: 16 KB");
|
||||
if (!text_->insertText(" ")) say("Can't type: " + problem_);
|
||||
break;
|
||||
case Key::Delete: text_->backspace(); break;
|
||||
case Key::Left: text_->left(); break;
|
||||
case Key::Right: text_->right(); break;
|
||||
case Key::Up: page ? text_->pageUp() : text_->up(); break;
|
||||
case Key::Down: page ? text_->pageDown() : text_->down(); break;
|
||||
case Key::Up: e.ctrl ? void(jump(false)) : page ? text_->pageUp() : text_->up(); break;
|
||||
case Key::Down: e.ctrl ? void(jump(true)) : page ? text_->pageDown() : text_->down(); break;
|
||||
case Key::Back:
|
||||
if (!dirty() || save()) return false;
|
||||
if (!owed() || save(true)) return false;
|
||||
ask_ = Ask::LeaveUnsaved;
|
||||
dialog_.reset(new DialogModel({"Stay", "Leave"}));
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
settle();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NoteEditor::update(uint32_t) {
|
||||
if (!text_) return false;
|
||||
if (!doc_) return false;
|
||||
uint32_t now = millis();
|
||||
// A save that failed is tried again every five seconds, not at every pass.
|
||||
if (dirty() && !dialog_ && now - lastTryMs_ >= kSaveAfterMs &&
|
||||
(now - lastKeyMs_ >= kSaveAfterMs || power_.screen() == ScreenState::Off))
|
||||
save();
|
||||
save(false);
|
||||
if (!message_.empty() && now - messageMs_ >= kMessageMs) {
|
||||
message_.clear();
|
||||
redraw_ = true;
|
||||
@@ -266,7 +370,8 @@ bool NoteEditor::update(uint32_t) {
|
||||
}
|
||||
|
||||
void NoteEditor::draw(Canvas& c) {
|
||||
if (!text_) return;
|
||||
if (!doc_) return;
|
||||
NoteText* text_ = &doc_->text();
|
||||
const auto& area = theme::kContent;
|
||||
c.setTextDatum(top_left);
|
||||
|
||||
@@ -275,7 +380,7 @@ void NoteEditor::draw(Canvas& c) {
|
||||
c.setTextColor(theme::kMuted);
|
||||
std::string name = path_.empty() ? "New note" : files::fitName(files::baseName(path_), 26);
|
||||
c.drawString(name.c_str(), 4, area.y + 1);
|
||||
std::string state = formatBytes(text_->text().size()) + (dirty() ? (problem_.empty() ? ", typing" : ", NOT SAVED") : ", saved");
|
||||
std::string state = formatBytes(doc_->size()) + (dirty() ? (problem_.empty() ? ", typing" : ", NOT SAVED") : ", saved");
|
||||
if (path_.empty() && !dirty()) state = "empty";
|
||||
c.setTextDatum(top_right);
|
||||
c.setTextColor(dirty() && !problem_.empty() ? theme::kWarning : theme::kMuted);
|
||||
@@ -288,9 +393,9 @@ void NoteEditor::draw(Canvas& c) {
|
||||
c.setTextColor(theme::kText);
|
||||
for (size_t i = 0; i < rows.size(); i++) c.drawString(rows[i].c_str(), 4, top + 1 + static_cast<int>(i) * theme::kLineHeight);
|
||||
c.fillRect(3 + text_->cursorCol() * 6, top + text_->cursorRow() * theme::kLineHeight, 1, theme::kLineHeight, theme::kAccent);
|
||||
if (text_->text().size() > static_cast<size_t>(kCols * kRows)) { // more than a screen: where we are in it
|
||||
if (doc_->size() > static_cast<uint32_t>(kCols * kRows)) { // more than a screen: where we are in it
|
||||
int h = kRows * theme::kLineHeight, barH = 12;
|
||||
c.fillRect(area.w - 2, top + (h - barH) * text_->percent() / 100, 2, barH, theme::kMuted);
|
||||
c.fillRect(area.w - 2, top + (h - barH) * doc_->percent() / 100, 2, barH, theme::kMuted);
|
||||
}
|
||||
|
||||
c.setFont(&fonts::small);
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
#include "dialog_model.h"
|
||||
#include "key_help.h"
|
||||
#include "key_event.h"
|
||||
#include "note_text.h"
|
||||
#include <functional>
|
||||
|
||||
#include "note_document.h"
|
||||
#include "services/clock_service.h"
|
||||
#include "services/power_service.h"
|
||||
#include "services/storage_service.h"
|
||||
@@ -15,10 +17,12 @@
|
||||
|
||||
namespace roro {
|
||||
|
||||
// Edits one text file of up to 16 KB (F1, Q143-Q145): the Notes App's editor, and the Storage
|
||||
// App's for `e`. It saves by itself: five seconds after the last key, when the screen turns off,
|
||||
// and on close. A save writes `<file>.tmp`, then puts it in the note's place, so the note on the
|
||||
// card is always a whole one.
|
||||
// Edits one text file of any size (F1, Q143-Q145; issue #47, Q223-Q232): the Notes App's editor,
|
||||
// and the Storage App's for `e`. The text is a notes::NoteDocument: a window of the file in
|
||||
// memory, the rest on the card. It saves by itself: five seconds after the last key, when the
|
||||
// screen turns off, and on close. Up to 64 KB a save writes `<file>.tmp` and puts it in the
|
||||
// note's place; a bigger note's saves go to `<file>.edit`, and the file is rewritten on leaving.
|
||||
// Either way the note on the card is always a whole one.
|
||||
class NoteEditor {
|
||||
public:
|
||||
static constexpr int kCols = 38, kRows = 8;
|
||||
@@ -30,9 +34,13 @@ class NoteEditor {
|
||||
std::string open(const std::string& path); // "" or why it can't be edited
|
||||
std::string openNew(const std::string& folder); // the same; no file until there's something to save (Q142)
|
||||
void close(); // saves what isn't yet
|
||||
bool isOpen() const { return static_cast<bool>(text_); }
|
||||
bool isOpen() const { return static_cast<bool>(doc_); }
|
||||
const std::string& path() const { return path_; } // "" for a new note nothing was typed in
|
||||
|
||||
// A long rewrite shows how far it is: the editor is waiting for the card meanwhile, so the
|
||||
// screen is drawn from here (set once, in main).
|
||||
static std::function<void(const std::string& name, int percent)> onProgress;
|
||||
|
||||
bool onKey(const KeyEvent& e); // false: done, and saved
|
||||
void help(std::vector<KeyHelp>& out) const;
|
||||
bool update(uint32_t nowMs); // true: draw again
|
||||
@@ -41,16 +49,24 @@ class NoteEditor {
|
||||
private:
|
||||
enum class Ask { None, Recover, LeaveUnsaved };
|
||||
|
||||
bool dirty() const { return text_ && text_->revision() != savedRevision_; }
|
||||
bool save(); // true if the card has it now (or there was nothing to save)
|
||||
class Card;
|
||||
bool dirty() const { return doc_ && doc_->dirty(); }
|
||||
bool owed() const { return doc_ && (doc_->dirty() || doc_->filePending()); } // the file isn't the note yet
|
||||
// True if the card has it now (or there was nothing to save). `whole`: the file itself is
|
||||
// rewritten; otherwise a note over 64 KB only gets its side file written, which is quick.
|
||||
bool save(bool whole);
|
||||
void settle(); // after a key: moves the window if the cursor is near an end of it
|
||||
bool jump(bool toEnd);
|
||||
void say(const std::string& text);
|
||||
|
||||
StorageService& storage_;
|
||||
ClockService& clock_;
|
||||
PowerService& power_;
|
||||
std::unique_ptr<notes::NoteText> text_;
|
||||
std::shared_ptr<Card> card_;
|
||||
std::unique_ptr<notes::NoteDocument> doc_;
|
||||
std::string path_, folder_;
|
||||
uint32_t savedRevision_ = 0, lastKeyMs_ = 0, lastTryMs_ = 0;
|
||||
uint32_t lastKeyMs_ = 0, lastTryMs_ = 0;
|
||||
bool givenUp_ = false; // "Leave" after a save that failed
|
||||
std::string recovered_; // what a temporary file left behind holds, until the user has chosen
|
||||
Ask ask_ = Ask::None;
|
||||
std::unique_ptr<DialogModel> dialog_;
|
||||
|
||||
@@ -25,6 +25,10 @@ bool endsWith(const std::string& s, const char* tail) {
|
||||
size_t n = std::strlen(tail);
|
||||
return s.size() >= n && s.compare(s.size() - n, n, tail) == 0;
|
||||
}
|
||||
|
||||
// Beside a note, and not one: a save cut short (.tmp), a long note's unsaved edits (.edit), and
|
||||
// edits that no longer fit their file, kept for whoever wants to look (.edit.lost).
|
||||
bool notANote(const std::string& name) { return endsWith(name, ".tmp") || endsWith(name, ".edit") || endsWith(name, ".edit.lost"); }
|
||||
} // namespace
|
||||
|
||||
void NotesApp::onEnter() {
|
||||
@@ -125,6 +129,7 @@ void NotesApp::onFinished(const FileOps::Status& s) {
|
||||
if (list_.find(name.substr(0, name.size() - 4)) < 0) orphans.push_back(name);
|
||||
continue;
|
||||
}
|
||||
if (notANote(name)) continue;
|
||||
notes_.push_back(static_cast<uint16_t>(i));
|
||||
}
|
||||
if (!orphans.empty() && !mended_) {
|
||||
@@ -202,6 +207,8 @@ bool NotesApp::onKey(const KeyEvent& e) {
|
||||
selectIndex_ = rows_.selected();
|
||||
selectAfter_.clear();
|
||||
std::string why = ops_.remove(target_, false);
|
||||
std::string side = target_ + ".edit"; // a long note's unsaved edits go with it (issue #47)
|
||||
if (why.empty()) storage_.runJob([side]() { SD.remove(side.c_str()); });
|
||||
if (why.empty()) wait_ = Wait::Work;
|
||||
else say(why);
|
||||
}
|
||||
@@ -225,6 +232,8 @@ bool NotesApp::onNameKey(const KeyEvent& e) {
|
||||
if (why.empty() && name != baseName(target_)) {
|
||||
why = ops_.move(target_, false, joinPath(kFolder, name));
|
||||
if (why.empty()) {
|
||||
std::string side = target_ + ".edit", sideTo = joinPath(kFolder, name) + ".edit";
|
||||
storage_.runJob([side, sideTo]() { SD.rename(side.c_str(), sideTo.c_str()); });
|
||||
wait_ = Wait::Work;
|
||||
selectAfter_ = name;
|
||||
}
|
||||
@@ -268,7 +277,7 @@ bool NotesApp::onListKey(const KeyEvent& e) {
|
||||
list_.sort(sort_);
|
||||
notes_.clear();
|
||||
for (size_t i = 0; i < list_.count(); i++)
|
||||
if (!list_.folder(i) && !endsWith(list_.name(i), ".tmp")) notes_.push_back(static_cast<uint16_t>(i));
|
||||
if (!list_.folder(i) && !notANote(list_.name(i))) notes_.push_back(static_cast<uint16_t>(i));
|
||||
for (size_t i = 0; i < notes_.size(); i++)
|
||||
if (keep == list_.name(notes_[i])) rows_.select(static_cast<int>(i));
|
||||
titlesFrom_ = -1;
|
||||
|
||||
@@ -52,6 +52,7 @@ class SettingsApp : public App {
|
||||
void draw(Canvas& c) override;
|
||||
void help(std::vector<KeyHelp>& out) const override;
|
||||
const char* helpTitle() const override;
|
||||
bool showsSecret() const override { return page_ == Page::Debug; } // the Debug Console's token
|
||||
|
||||
private:
|
||||
enum class Page { Menu, Text, Choice, About, Wifi, Firmware, Debug };
|
||||
|
||||
@@ -26,7 +26,7 @@ void ShellApp::onEnter() {
|
||||
scroll_ = 0;
|
||||
confirm_.reset();
|
||||
// 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_.add(open_ ? "The console's commands. `help` lists them." : "No memory for the Shell: leave an App, or stop IRC.");
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ void StorageApp::onEnter() {
|
||||
}
|
||||
|
||||
void StorageApp::onExit() {
|
||||
share_.stop(); // sharing lasts as long as its screen
|
||||
if (wait_ != Wait::None && wait_ != Wait::Work) {
|
||||
ops_.cancel();
|
||||
wait_ = Wait::None;
|
||||
@@ -97,6 +98,13 @@ bool StorageApp::work(const std::string& why, const std::string& selectAfter) {
|
||||
|
||||
void StorageApp::update(uint32_t nowMs) {
|
||||
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
|
||||
cardPresent_ = present;
|
||||
cwd_ = want_ = "/";
|
||||
@@ -281,6 +289,7 @@ void StorageApp::help(std::vector<KeyHelp>& out) const {
|
||||
case View::Editor: return noteEditor_.help(out);
|
||||
case View::Viewer: return viewer_.help(out);
|
||||
case View::Details: return keys::add(out, keys::kStorageDetails);
|
||||
case View::Share: return keys::add(out, keys::kStorageShare);
|
||||
default: break;
|
||||
}
|
||||
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::Viewer: return "Storage: a file";
|
||||
case View::Details: return "Storage: details";
|
||||
case View::Share: return "Storage: sharing";
|
||||
case View::Name: return "Storage: a name";
|
||||
default: return nullptr;
|
||||
}
|
||||
@@ -303,6 +313,14 @@ const char* StorageApp::helpTitle() const {
|
||||
bool StorageApp::onKey(const KeyEvent& e) {
|
||||
requestRedraw();
|
||||
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 (!maintenance_.onKey(e)) {
|
||||
view_ = View::Browse;
|
||||
@@ -322,7 +340,6 @@ bool StorageApp::onKey(const KeyEvent& e) {
|
||||
if (view_ == View::Viewer && viewer_.showingText() && e.key == Key::Char && (e.ch == 'e' || e.ch == 'E')) {
|
||||
// Edit it (Q146), if the rules and its size allow.
|
||||
std::string path = viewer_.path(), why = ops_.whyReadOnly(path, false);
|
||||
if (why.empty() && viewer_.size() > notes::NoteText::kMaxBytes) why = "Too big to edit: 16 KB at most";
|
||||
if (why.empty()) {
|
||||
viewer_.close();
|
||||
why = noteEditor_.open(path);
|
||||
@@ -414,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;
|
||||
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;
|
||||
|
||||
Item item = selected();
|
||||
@@ -508,6 +531,7 @@ void StorageApp::draw(Canvas& c) {
|
||||
c.drawString("in Gemini) and open Storage again.", 4, area.y + 22 + theme::kLineHeight);
|
||||
return;
|
||||
case View::Maintenance: maintenance_.draw(c); return;
|
||||
case View::Share: drawShare(c); return;
|
||||
case View::Viewer: viewer_.draw(c); return;
|
||||
case View::Editor: noteEditor_.draw(c); return;
|
||||
case View::Details: {
|
||||
@@ -540,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) {
|
||||
const auto& area = theme::kContent;
|
||||
StorageState card = storage_.state();
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "services/clock_service.h"
|
||||
#include "services/file_ops.h"
|
||||
#include "services/storage_service.h"
|
||||
#include "services/web_share.h"
|
||||
#include "ui/theme.h"
|
||||
|
||||
namespace roro {
|
||||
@@ -26,19 +27,21 @@ namespace roro {
|
||||
// usage, Storage Clean-up and erasing the card, behind a warning (Q128).
|
||||
class StorageApp : public App {
|
||||
public:
|
||||
StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, PowerService& power, EventBus& bus)
|
||||
: ops_(ops), storage_(storage), bus_(bus), maintenance_(storage, clock, bus), viewer_(storage, update), noteEditor_(storage, clock, power) {}
|
||||
StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, PowerService& power, EventBus& bus, WebShare& share)
|
||||
: ops_(ops), storage_(storage), bus_(bus), share_(share), maintenance_(storage, clock, bus), viewer_(storage, update), noteEditor_(storage, clock, power) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
bool textEntryActive() const override { return view_ == View::Name || view_ == View::Editor; }
|
||||
void update(uint32_t nowMs) override;
|
||||
void draw(Canvas& c) override;
|
||||
bool retainsContent() const override { return view_ == View::Viewer && viewer_.retains(); }
|
||||
void contentLost() override { viewer_.lost(); }
|
||||
void help(std::vector<KeyHelp>& out) const override;
|
||||
const char* helpTitle() const override;
|
||||
|
||||
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 Wait { None, List, CountToDelete, CountForDetails, Work }; // what the running operation is for
|
||||
static constexpr int kRows = 8;
|
||||
@@ -64,10 +67,12 @@ class StorageApp : public App {
|
||||
void say(const std::string& text);
|
||||
void drawBrowse(Canvas& c);
|
||||
void drawProgress(Canvas& c);
|
||||
void drawShare(Canvas& c); // `w`: the card in a browser on the same network (issue #88)
|
||||
|
||||
FileOps& ops_;
|
||||
StorageService& storage_;
|
||||
EventBus& bus_;
|
||||
WebShare& share_;
|
||||
MaintenancePage maintenance_;
|
||||
FileViewer viewer_;
|
||||
NoteEditor noteEditor_; // `e` in the text viewer (Q146)
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
#include "app_manager.h"
|
||||
#include "png_rgb332.h"
|
||||
#include "apps/demo_app.h"
|
||||
#include "apps/note_editor.h"
|
||||
#include "apps/shell_app.h"
|
||||
#include "services/web_share.h"
|
||||
#include "apps/gemini_app.h"
|
||||
#include "apps/gnss_app.h"
|
||||
#include "apps/irc_app.h"
|
||||
@@ -215,11 +217,13 @@ void setup() {
|
||||
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)});
|
||||
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)});
|
||||
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({"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.
|
||||
power->beforePowerOff = []() { apps->home(); };
|
||||
// A long note being rewritten (issue #47): the editor waits for the card, so it draws from there.
|
||||
NoteEditor::onProgress = [](const std::string& name, int percent) { screen.renderUpdate("Saving", name, percent); };
|
||||
apps->registerApp({"system", "System", false,
|
||||
new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs)});
|
||||
apps->registerApp({"settings", "Settings", false,
|
||||
@@ -653,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"
|
||||
"crash the last crash: firmware, reason, task, backtrace\n"
|
||||
"coredump erase forget the core dump in flash\n"
|
||||
"key <name|char> press a key: up down left right select back home del tab space help, or one character\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 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"
|
||||
@@ -722,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() {
|
||||
if (shotPending && static_cast<int32_t>(millis() - shotDueMs) >= 0) {
|
||||
shotPending = false;
|
||||
@@ -1026,13 +1050,24 @@ static void runCommand(String line, bool fromSerial = false) {
|
||||
bus.publish(Event::withText(EventType::Notification, ("Burst " + String(i)).c_str(), 0));
|
||||
if (line.startsWith("key ")) { // key up|down|left|right|select|back|home|del|tab
|
||||
String k = line.substring(4);
|
||||
// ctrl- alt- shift- before the name, in any order: `key ctrl-down`, `key alt-up`, `key ctrl-b`.
|
||||
bool ctrl = false, alt = false, shift = false;
|
||||
for (bool more = true; more;) {
|
||||
more = false;
|
||||
if (k.startsWith("ctrl-") && k.length() > 5) ctrl = more = true, k = k.substring(5);
|
||||
if (k.startsWith("alt-") && k.length() > 4) alt = more = true, k = k.substring(4);
|
||||
if (k.startsWith("shift-") && k.length() > 6) shift = more = true, k = k.substring(6);
|
||||
}
|
||||
Key key = k == "up" ? Key::Up : k == "down" ? Key::Down : k == "left" ? Key::Left
|
||||
: k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home
|
||||
: k == "del" ? Key::Delete : k == "tab" ? Key::Tab : 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(' ')
|
||||
: k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0])
|
||||
: KeyEvent::of(key);
|
||||
if (!power->onKey(millis())) apps->handleKey(ev);
|
||||
ev.ctrl = ctrl;
|
||||
ev.alt = alt;
|
||||
ev.shift = shift;
|
||||
if (!power->onKey(millis())) handleKey(ev);
|
||||
}
|
||||
if (line.startsWith("wifi add ")) { // wifi add <ssid>\t<password>: credentials never touch the repo
|
||||
int tab = line.indexOf('\t');
|
||||
@@ -1371,7 +1406,7 @@ static void loopPass() {
|
||||
keyMapper.setTextEntry(apps->foreground().textEntryActive());
|
||||
auto events = keyMapper.update(readKeys()); // always, so held keys are tracked
|
||||
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,
|
||||
|
||||
@@ -42,6 +42,7 @@ void PowerService::applyScreen(ScreenState state) {
|
||||
}
|
||||
|
||||
void PowerService::powerOff() {
|
||||
poweringOff_ = true;
|
||||
if (beforePowerOff) beforePowerOff();
|
||||
auto& display = M5Cardputer.Display;
|
||||
display.wakeup();
|
||||
|
||||
@@ -28,6 +28,7 @@ class PowerService : public Service {
|
||||
|
||||
// Run before the device powers off: the last chance to put on the card what's only in memory.
|
||||
std::function<void()> beforePowerOff;
|
||||
bool poweringOff() const { return poweringOff_; } // from beforePowerOff on: no time for long work
|
||||
|
||||
private:
|
||||
void applyScreen(ScreenState state);
|
||||
@@ -38,6 +39,7 @@ class PowerService : public Service {
|
||||
PowerButton button_{2000};
|
||||
ScreenState applied_ = ScreenState::On;
|
||||
int appliedBrightness_ = -1;
|
||||
bool poweringOff_ = false;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -13,10 +13,23 @@ bool Screen::begin() {
|
||||
}
|
||||
|
||||
void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast) {
|
||||
canvas_.fillSprite(theme::kBackground);
|
||||
const auto& area = theme::kContent;
|
||||
App& app = apps.foreground();
|
||||
// An App that keeps what it drew: only the Status Bar is cleared, unless the App has changed
|
||||
// or a Toast or the help panel, drawn over it, has just gone.
|
||||
bool overlay = apps.help().isOpen() || toast, retains = app.retainsContent();
|
||||
if (overlayWas_ && !overlay) lost_ = true;
|
||||
if (retains && &app == lastApp_ && !lost_) {
|
||||
canvas_.fillRect(0, 0, theme::kWidth, area.y, theme::kBackground);
|
||||
} else {
|
||||
canvas_.fillSprite(theme::kBackground);
|
||||
if (retains) app.contentLost();
|
||||
}
|
||||
lastApp_ = &app;
|
||||
lost_ = false;
|
||||
overlayWas_ = overlay && retains;
|
||||
canvas_.setClipRect(area.x, area.y, area.w, area.h);
|
||||
apps.foreground().draw(canvas_);
|
||||
app.draw(canvas_);
|
||||
if (apps.help().isOpen()) widgets::help(canvas_, apps.help()); // over the App, under the Status Bar
|
||||
canvas_.clearClipRect();
|
||||
widgets::statusBar(canvas_, status);
|
||||
@@ -25,6 +38,7 @@ void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toa
|
||||
}
|
||||
|
||||
void Screen::renderUpdate(const std::string& title, const std::string& detail, int percent) {
|
||||
lost_ = true;
|
||||
canvas_.fillSprite(theme::kBackground);
|
||||
canvas_.setTextDatum(top_center);
|
||||
canvas_.setFont(&fonts::bold);
|
||||
|
||||
@@ -17,6 +17,8 @@ class Screen {
|
||||
|
||||
private:
|
||||
Canvas canvas_;
|
||||
const App* lastApp_ = nullptr; // for an App that keeps what it drew (App::retainsContent)
|
||||
bool lost_ = true, overlayWas_ = false;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "image_file.h"
|
||||
#include "images.h"
|
||||
#include "png_rgb332.h"
|
||||
|
||||
using namespace roro::files;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
namespace {
|
||||
ImageRead from(const std::string& bytes) {
|
||||
return [&bytes](uint32_t at, uint8_t* into, size_t len) -> size_t {
|
||||
if (at >= bytes.size()) return 0;
|
||||
size_t n = std::min(len, bytes.size() - at);
|
||||
std::memcpy(into, bytes.data() + at, n);
|
||||
return n;
|
||||
};
|
||||
}
|
||||
template <size_t N>
|
||||
std::string str(const uint8_t (&a)[N]) { return std::string(reinterpret_cast<const char*>(a), N); }
|
||||
|
||||
// The palette and the two patterns the test files were drawn with.
|
||||
void colour(int i, uint8_t* rgb) {
|
||||
rgb[0] = static_cast<uint8_t>((i * 7) % 256);
|
||||
rgb[1] = static_cast<uint8_t>((255 - i * 3) % 256);
|
||||
rgb[2] = static_cast<uint8_t>((i * 13 + 40) % 256);
|
||||
}
|
||||
int small(int x, int y) { return (x * 3 + y * 5) % 7; }
|
||||
int big(int x, int y) { return (x * x + y * y * 3 + x * y) & 0xFF; }
|
||||
|
||||
struct Picture {
|
||||
int w, h;
|
||||
std::vector<int> rgb; // -1: never drawn
|
||||
Picture(int width, int height) : w(width), h(height), rgb(static_cast<size_t>(width * height), -1) {}
|
||||
ImagePixels sink() {
|
||||
return [this](int x, int y, int count, const uint8_t* p) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
TEST_ASSERT_TRUE(x + i >= 0 && x + i < w && y >= 0 && y < h);
|
||||
rgb[static_cast<size_t>(y * w + x + i)] = (p[i * 3] << 16) | (p[i * 3 + 1] << 8) | p[i * 3 + 2];
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
void checkGif(const std::string& file, int w, int h, int (*index)(int, int), int transparent = -1) {
|
||||
Picture p(w, h);
|
||||
TEST_ASSERT_EQUAL_STRING("", readGif(from(file), static_cast<uint32_t>(file.size()), p.sink()).c_str());
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++) {
|
||||
uint8_t c[3];
|
||||
colour(index(x, y), c);
|
||||
int want = index(x, y) == transparent ? -1 : (c[0] << 16) | (c[1] << 8) | c[2];
|
||||
char msg[40];
|
||||
std::snprintf(msg, sizeof msg, "at %d,%d", x, y);
|
||||
TEST_ASSERT_EQUAL_HEX32_MESSAGE(want, p.rgb[static_cast<size_t>(y * w + x)], msg);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void test_kinds() {
|
||||
TEST_ASSERT_TRUE(imageKindOfName("Photo.JPG") == ImageKind::Jpeg);
|
||||
TEST_ASSERT_TRUE(imageKindOfName("a.jpeg") == ImageKind::Jpeg);
|
||||
TEST_ASSERT_TRUE(imageKindOfName("20261007-104357.png") == ImageKind::Png);
|
||||
TEST_ASSERT_TRUE(imageKindOfName("x.bmp") == ImageKind::Bmp);
|
||||
TEST_ASSERT_TRUE(imageKindOfName("x.gif") == ImageKind::Gif);
|
||||
TEST_ASSERT_TRUE(imageKindOfName("notes.txt") == ImageKind::None);
|
||||
const uint8_t jpeg[] = {0xFF, 0xD8, 0xFF, 0xE0};
|
||||
TEST_ASSERT_TRUE(imageKindOfBytes(jpeg, 4) == ImageKind::Jpeg);
|
||||
TEST_ASSERT_TRUE(imageKindOfBytes(kGifSmall, 6) == ImageKind::Gif);
|
||||
TEST_ASSERT_TRUE(imageKindOfBytes(kBmp24, 8) == ImageKind::Bmp);
|
||||
TEST_ASSERT_TRUE(imageKindOfBytes(reinterpret_cast<const uint8_t*>("Hello"), 5) == ImageKind::None);
|
||||
}
|
||||
|
||||
void test_sizes() {
|
||||
ImageInfo info;
|
||||
std::string gif = str(kGifBig), bmp = str(kBmp24);
|
||||
TEST_ASSERT_EQUAL_STRING("", imageInfo(from(gif), static_cast<uint32_t>(gif.size()), info).c_str());
|
||||
TEST_ASSERT_TRUE(info.kind == ImageKind::Gif && info.width == 96 && info.height == 64);
|
||||
TEST_ASSERT_EQUAL_STRING("", imageInfo(from(bmp), static_cast<uint32_t>(bmp.size()), info).c_str());
|
||||
TEST_ASSERT_TRUE(info.kind == ImageKind::Bmp && info.width == 13 && info.height == 7);
|
||||
|
||||
// A JPEG: start, an application segment, a table, then the frame header with the size.
|
||||
std::string jpeg("\xFF\xD8", 2);
|
||||
jpeg += std::string("\xFF\xE1\x00\x08" "Exif\0\0", 10);
|
||||
jpeg += std::string("\xFF\xDB\x00\x04\x00\x00", 6);
|
||||
std::string frame("\xFF\xC0\x00\x11\x08\x0B\xB8\x0F\xA0\x03", 10); // 3000 high, 4000 wide
|
||||
std::string baseline = jpeg + frame + std::string(20, '\0');
|
||||
TEST_ASSERT_EQUAL_STRING("", imageInfo(from(baseline), static_cast<uint32_t>(baseline.size()), info).c_str());
|
||||
TEST_ASSERT_TRUE(info.kind == ImageKind::Jpeg && info.width == 4000 && info.height == 3000);
|
||||
frame[1] = '\xC2';
|
||||
std::string progressive = jpeg + frame + std::string(20, '\0');
|
||||
TEST_ASSERT_EQUAL_STRING("A progressive JPEG can't be shown", imageInfo(from(progressive), static_cast<uint32_t>(progressive.size()), info).c_str());
|
||||
std::string cut = jpeg;
|
||||
TEST_ASSERT_EQUAL_STRING("This JPEG is damaged", imageInfo(from(cut), static_cast<uint32_t>(cut.size()), info).c_str());
|
||||
|
||||
std::string text = "Just some words, not a picture at all.";
|
||||
TEST_ASSERT_EQUAL_STRING("Not a picture this can show", imageInfo(from(text), static_cast<uint32_t>(text.size()), info).c_str());
|
||||
}
|
||||
|
||||
void test_a_screenshot_is_read_without_decoding() {
|
||||
const int w = 240, h = 135;
|
||||
std::string file;
|
||||
roro::png::Rgb332Writer writer(w, h, [&](const uint8_t* d, size_t n) {
|
||||
file.append(reinterpret_cast<const char*>(d), n);
|
||||
return true;
|
||||
});
|
||||
TEST_ASSERT_TRUE(writer.begin());
|
||||
std::vector<uint8_t> row(w);
|
||||
for (int y = 0; y < h; y++) {
|
||||
for (int x = 0; x < w; x++) row[static_cast<size_t>(x)] = static_cast<uint8_t>(x * 3 + y * 7);
|
||||
TEST_ASSERT_TRUE(writer.row(row.data()));
|
||||
}
|
||||
TEST_ASSERT_TRUE(writer.end());
|
||||
ImageInfo info;
|
||||
TEST_ASSERT_EQUAL_STRING("", imageInfo(from(file), static_cast<uint32_t>(file.size()), info).c_str());
|
||||
TEST_ASSERT_TRUE(info.kind == ImageKind::Png && info.width == w && info.height == h);
|
||||
uint32_t at = screenshotPixelsAt(from(file), static_cast<uint32_t>(file.size()), w, h);
|
||||
TEST_ASSERT_TRUE(at > 0);
|
||||
for (int y : {0, 1, 77, 134})
|
||||
for (int x : {0, 5, 239})
|
||||
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(x * 3 + y * 7), static_cast<uint8_t>(file[at + static_cast<uint32_t>(y * (w + 1) + x)]));
|
||||
// Another PNG isn't taken for one: a byte more, a compressed block.
|
||||
std::string longer = file + "x";
|
||||
TEST_ASSERT_EQUAL_UINT32(0, screenshotPixelsAt(from(longer), static_cast<uint32_t>(longer.size()), w, h));
|
||||
std::string other = file;
|
||||
other[8 + 25 + 780 + 10] = 0x05;
|
||||
TEST_ASSERT_EQUAL_UINT32(0, screenshotPixelsAt(from(other), static_cast<uint32_t>(other.size()), w, h));
|
||||
}
|
||||
|
||||
void test_dithering_leaves_the_screens_own_colours_alone() {
|
||||
for (int v = 0; v < 256; v++) {
|
||||
uint8_t r = static_cast<uint8_t>((v >> 5) * 255 / 7), g = static_cast<uint8_t>(((v >> 2) & 7) * 255 / 7), b = static_cast<uint8_t>((v & 3) * 85);
|
||||
for (int y = 0; y < 4; y++)
|
||||
for (int x = 0; x < 4; x++) TEST_ASSERT_EQUAL_UINT8(v, rgb332Dithered(r, g, b, x, y));
|
||||
}
|
||||
// A grey between two levels comes out as a mix of both, in proportion.
|
||||
int high = 0;
|
||||
for (int y = 0; y < 4; y++)
|
||||
for (int x = 0; x < 4; x++) {
|
||||
int red = rgb332Dithered(54, 0, 0, x, y) >> 5; // half way from 36 to 72
|
||||
TEST_ASSERT_TRUE(red == 1 || red == 2);
|
||||
high += red == 2;
|
||||
}
|
||||
TEST_ASSERT_TRUE(high >= 6 && high <= 10);
|
||||
TEST_ASSERT_EQUAL_UINT8(0xFF, rgb332Dithered(255, 255, 255, 1, 2));
|
||||
TEST_ASSERT_EQUAL_UINT8(0x00, rgb332Dithered(0, 0, 0, 3, 3));
|
||||
}
|
||||
|
||||
void test_a_small_picture_sits_in_the_middle() {
|
||||
ImageFrame f(100, 50, 0, 12, 240, 123);
|
||||
TEST_ASSERT_FALSE(f.bigger());
|
||||
TEST_ASSERT_EQUAL_INT(100, f.percent());
|
||||
ImageMap m = f.map();
|
||||
int tx, ty;
|
||||
TEST_ASSERT_TRUE(m.at(0, 0, tx, ty));
|
||||
TEST_ASSERT_EQUAL_INT(70, tx);
|
||||
TEST_ASSERT_EQUAL_INT(12 + 36, ty);
|
||||
TEST_ASSERT_TRUE(m.at(99, 49, tx, ty));
|
||||
TEST_ASSERT_EQUAL_INT(169, tx);
|
||||
TEST_ASSERT_FALSE(m.at(-1, 0, tx, ty));
|
||||
f.toggle(); // nothing to zoom
|
||||
TEST_ASSERT_FALSE(f.actual());
|
||||
TEST_ASSERT_EQUAL_INT(0, f.jpegShrink());
|
||||
}
|
||||
|
||||
void test_a_big_picture_is_shrunk_to_fit_each_screen_pixel_once() {
|
||||
ImageFrame f(4000, 3000, 0, 12, 240, 123);
|
||||
TEST_ASSERT_TRUE(f.bigger());
|
||||
TEST_ASSERT_EQUAL_INT(4, f.percent()); // 123 / 3000
|
||||
ImageMap m = f.map();
|
||||
std::vector<int> hits(240 * 135, 0);
|
||||
int rows = 0;
|
||||
for (int y = 0; y < 3000; y++) {
|
||||
if (!m.rowUsed(y)) continue;
|
||||
rows++;
|
||||
for (int x = 0; x < 4000; x++) {
|
||||
int tx, ty;
|
||||
if (m.at(x, y, tx, ty)) hits[static_cast<size_t>(ty * 240 + tx)]++;
|
||||
}
|
||||
}
|
||||
TEST_ASSERT_EQUAL_INT(123, rows);
|
||||
int drawn = 0;
|
||||
for (int ty = 0; ty < 135; ty++)
|
||||
for (int tx = 0; tx < 240; tx++) {
|
||||
int n = hits[static_cast<size_t>(ty * 240 + tx)];
|
||||
TEST_ASSERT_TRUE(n <= 1);
|
||||
drawn += n;
|
||||
if (n) TEST_ASSERT_TRUE(ty >= 12 && tx >= 38 && tx < 202); // 164 wide, centred
|
||||
}
|
||||
TEST_ASSERT_EQUAL_INT(164 * 123, drawn);
|
||||
|
||||
// A JPEG decoder may halve it three times first; the map then takes those pixels.
|
||||
TEST_ASSERT_EQUAL_INT(3, f.jpegShrink());
|
||||
ImageMap j = f.map(3);
|
||||
int tx, ty, again = 0;
|
||||
for (int y = 0; y < 375; y++)
|
||||
for (int x = 0; x < 500; x++) again += j.at(x, y, tx, ty);
|
||||
TEST_ASSERT_EQUAL_INT(164 * 123, again);
|
||||
TEST_ASSERT_EQUAL_INT(0, ImageFrame(300, 200, 0, 12, 240, 123).jpegShrink()); // not by half: it would be too small
|
||||
TEST_ASSERT_EQUAL_INT(1, ImageFrame(480, 246, 0, 12, 240, 123).jpegShrink());
|
||||
}
|
||||
|
||||
void test_at_its_own_size_it_moves_half_a_screen_at_a_time() {
|
||||
ImageFrame f(800, 600, 0, 12, 240, 123);
|
||||
f.toggle();
|
||||
TEST_ASSERT_TRUE(f.actual());
|
||||
TEST_ASSERT_EQUAL_INT(100, f.percent());
|
||||
ImageMap m = f.map();
|
||||
int tx, ty;
|
||||
TEST_ASSERT_TRUE(m.at(280, 238, tx, ty)); // the middle first
|
||||
TEST_ASSERT_EQUAL_INT(0, tx);
|
||||
TEST_ASSERT_EQUAL_INT(12, ty);
|
||||
TEST_ASSERT_FALSE(m.at(279, 238, tx, ty));
|
||||
TEST_ASSERT_FALSE(m.below(238 + 122)); // the last row on screen
|
||||
TEST_ASSERT_TRUE(m.below(238 + 123)); // from here on a decoder can stop
|
||||
TEST_ASSERT_TRUE(f.pan(1, 0));
|
||||
TEST_ASSERT_TRUE(f.map().at(400, 238, tx, ty));
|
||||
TEST_ASSERT_EQUAL_INT(0, tx);
|
||||
for (int i = 0; i < 10; i++) f.pan(1, 1);
|
||||
TEST_ASSERT_FALSE(f.pan(1, 0)); // the corner: no further
|
||||
TEST_ASSERT_TRUE(f.map().at(799, 599, tx, ty));
|
||||
TEST_ASSERT_EQUAL_INT(239, tx);
|
||||
TEST_ASSERT_EQUAL_INT(12 + 122, ty);
|
||||
f.toggle();
|
||||
TEST_ASSERT_FALSE(f.actual());
|
||||
TEST_ASSERT_FALSE(f.pan(1, 0)); // fitted: nothing to move
|
||||
}
|
||||
|
||||
void test_gif() {
|
||||
checkGif(str(kGifSmall), 16, 12, small);
|
||||
checkGif(str(kGifSmallInterlaced), 16, 12, small);
|
||||
checkGif(str(kGifBig), 96, 64, big); // long enough for the codes to grow to 12 bits and start over
|
||||
checkGif(str(kGifBigInterlaced), 96, 64, big);
|
||||
checkGif(str(kGifTransparent), 16, 12, small, 3);
|
||||
}
|
||||
|
||||
void test_gif_damaged() {
|
||||
Picture p(96, 64);
|
||||
std::string cut = str(kGifBig).substr(0, 3000);
|
||||
TEST_ASSERT_EQUAL_STRING("This GIF is cut short", readGif(from(cut), static_cast<uint32_t>(cut.size()), p.sink()).c_str());
|
||||
std::string head = str(kGifBig).substr(0, 10);
|
||||
TEST_ASSERT_EQUAL_STRING("This GIF is damaged", readGif(from(head), static_cast<uint32_t>(head.size()), p.sink()).c_str());
|
||||
// Any byte changed: an answer, never a crash or a pixel outside the picture (the sink checks).
|
||||
std::string file = str(kGifBig);
|
||||
for (size_t i = 0; i < file.size(); i += 37) {
|
||||
std::string bad = file;
|
||||
bad[i] = static_cast<char>(bad[i] ^ 0x5A);
|
||||
Picture q(96, 64);
|
||||
readGif(from(bad), static_cast<uint32_t>(bad.size()), q.sink());
|
||||
}
|
||||
}
|
||||
|
||||
void test_bmp() {
|
||||
std::string file = str(kBmp24);
|
||||
Picture p(13, 7);
|
||||
TEST_ASSERT_EQUAL_STRING("", readBmp(from(file), static_cast<uint32_t>(file.size()), p.sink()).c_str());
|
||||
for (int y = 0; y < 7; y++)
|
||||
for (int x = 0; x < 13; x++)
|
||||
TEST_ASSERT_EQUAL_HEX32((((x * 9) % 256) << 16) | (((y * 17) % 256) << 8) | ((x + y) % 256), p.rgb[static_cast<size_t>(y * 13 + x)]);
|
||||
std::string file8 = str(kBmp8);
|
||||
Picture q(13, 7);
|
||||
TEST_ASSERT_EQUAL_STRING("", readBmp(from(file8), static_cast<uint32_t>(file8.size()), q.sink()).c_str());
|
||||
for (int i = 0; i < 13 * 7; i++)
|
||||
TEST_ASSERT_EQUAL_HEX32((kBmp8Pixels[i * 3] << 16) | (kBmp8Pixels[i * 3 + 1] << 8) | kBmp8Pixels[i * 3 + 2], q.rgb[static_cast<size_t>(i)]);
|
||||
// Only the rows asked for are read.
|
||||
Picture r(13, 7);
|
||||
readBmp(from(file), static_cast<uint32_t>(file.size()), r.sink(), [](int y) { return y == 2; });
|
||||
TEST_ASSERT_EQUAL_INT(-1, r.rgb[0]);
|
||||
TEST_ASSERT_TRUE(r.rgb[2 * 13] >= 0);
|
||||
std::string cut = file.substr(0, 200);
|
||||
TEST_ASSERT_EQUAL_STRING("This BMP is cut short", readBmp(from(cut), static_cast<uint32_t>(cut.size()), p.sink()).c_str());
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_kinds);
|
||||
RUN_TEST(test_sizes);
|
||||
RUN_TEST(test_a_screenshot_is_read_without_decoding);
|
||||
RUN_TEST(test_dithering_leaves_the_screens_own_colours_alone);
|
||||
RUN_TEST(test_a_small_picture_sits_in_the_middle);
|
||||
RUN_TEST(test_a_big_picture_is_shrunk_to_fit_each_screen_pixel_once);
|
||||
RUN_TEST(test_at_its_own_size_it_moves_half_a_screen_at_a_time);
|
||||
RUN_TEST(test_gif);
|
||||
RUN_TEST(test_gif_damaged);
|
||||
RUN_TEST(test_bmp);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -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() {
|
||||
for (bool typing : {true, false}) {
|
||||
KeyMapper m;
|
||||
@@ -278,6 +294,7 @@ int main() {
|
||||
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_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_question_mark_is_help_only_when_not_typing);
|
||||
return UNITY_END();
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <random>
|
||||
#include <string>
|
||||
|
||||
#include "note_document.h"
|
||||
|
||||
using namespace roro::notes;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
namespace {
|
||||
// A card in memory. `budget`: how many more bytes it takes before the power is cut: the write
|
||||
// that crosses it is left half done, and nothing works after it.
|
||||
struct FakeCard : NoteCard {
|
||||
std::map<std::string, std::string> files;
|
||||
int64_t budget = -1;
|
||||
bool dead = false;
|
||||
uint64_t free = 1ull << 30;
|
||||
int reads = 0;
|
||||
|
||||
bool spend(size_t n) {
|
||||
if (dead) return false;
|
||||
if (budget < 0) return true;
|
||||
if (static_cast<int64_t>(n) <= budget) return budget -= static_cast<int64_t>(n), true;
|
||||
return false;
|
||||
}
|
||||
bool size(const std::string& path, uint32_t& size) override {
|
||||
auto it = files.find(path);
|
||||
if (dead || it == files.end()) return false;
|
||||
size = static_cast<uint32_t>(it->second.size());
|
||||
return true;
|
||||
}
|
||||
size_t read(const std::string& path, uint32_t at, uint8_t* into, size_t len) override {
|
||||
auto it = files.find(path);
|
||||
if (dead || it == files.end() || at > it->second.size()) return 0;
|
||||
reads++;
|
||||
size_t n = std::min(len, it->second.size() - at);
|
||||
std::copy(it->second.begin() + at, it->second.begin() + at + static_cast<long>(n), into);
|
||||
return n;
|
||||
}
|
||||
bool create(const std::string& path) override {
|
||||
if (!spend(1)) return dead = true, false;
|
||||
files[path].clear();
|
||||
return true;
|
||||
}
|
||||
bool append(const std::string& path, const uint8_t* data, size_t len) override {
|
||||
if (dead) return false;
|
||||
if (!spend(len)) {
|
||||
files[path].append(reinterpret_cast<const char*>(data), static_cast<size_t>(std::max<int64_t>(0, budget)));
|
||||
return dead = true, false;
|
||||
}
|
||||
files[path].append(reinterpret_cast<const char*>(data), len);
|
||||
return true;
|
||||
}
|
||||
bool remove(const std::string& path) override {
|
||||
if (!spend(1)) return dead = true, false;
|
||||
return files.erase(path) > 0;
|
||||
}
|
||||
bool rename(const std::string& from, const std::string& to) override {
|
||||
if (!spend(1)) return dead = true, false;
|
||||
if (!files.count(from) || files.count(to)) return false;
|
||||
files[to] = files[from];
|
||||
files.erase(from);
|
||||
return true;
|
||||
}
|
||||
uint64_t freeBytes() override { return free; }
|
||||
void powerBack() {
|
||||
dead = false;
|
||||
budget = -1;
|
||||
}
|
||||
};
|
||||
|
||||
constexpr int kCols = 38, kRows = 8;
|
||||
const char* const kNote = "/notes/big.txt";
|
||||
|
||||
std::string lines(int from, int count) {
|
||||
std::string s;
|
||||
char b[32];
|
||||
for (int i = 0; i < count; i++) {
|
||||
std::snprintf(b, sizeof b, "line %06d\n", from + i);
|
||||
s += b;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Each line is 12 bytes: "line 000000\n".
|
||||
constexpr uint32_t kLine = 12;
|
||||
|
||||
bool rewrite(NoteDocument& d) {
|
||||
std::string why;
|
||||
if (!d.rewriteStart(why)) return false;
|
||||
for (int guard = 0; guard < 100000; guard++) {
|
||||
int p = d.rewriteStep(why);
|
||||
if (p == 100) return true;
|
||||
if (p < 0) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void settle(NoteDocument& d) {
|
||||
std::string why;
|
||||
if (d.wantsMove()) TEST_ASSERT_TRUE_MESSAGE(d.move(why), why.c_str());
|
||||
}
|
||||
|
||||
void type(NoteDocument& d, const std::string& s) {
|
||||
for (char c : s) {
|
||||
TEST_ASSERT_TRUE(d.text().insert(static_cast<uint8_t>(c)));
|
||||
settle(d);
|
||||
}
|
||||
}
|
||||
|
||||
std::string currentRow(NoteDocument& d) { return d.text().rows()[static_cast<size_t>(d.text().cursorRow())]; }
|
||||
} // namespace
|
||||
|
||||
void test_a_small_note_is_all_window() {
|
||||
FakeCard card;
|
||||
card.files[kNote] = "Hello\nworld\n";
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
TEST_ASSERT_EQUAL_STRING("", d.open(kNote).c_str());
|
||||
TEST_ASSERT_FALSE(d.windowed());
|
||||
TEST_ASSERT_FALSE(d.dirty());
|
||||
TEST_ASSERT_TRUE(d.wantsRewrite());
|
||||
type(d, "Oh! ");
|
||||
TEST_ASSERT_TRUE(d.dirty());
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
TEST_ASSERT_FALSE(d.dirty());
|
||||
TEST_ASSERT_EQUAL_STRING("Oh! Hello\nworld\n", card.files[kNote].c_str());
|
||||
TEST_ASSERT_EQUAL_size_t(1, card.files.size()); // no side file, no temporary file left
|
||||
}
|
||||
|
||||
void test_a_new_note_has_no_file_until_it_is_rewritten() {
|
||||
FakeCard card;
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
type(d, "A list");
|
||||
TEST_ASSERT_TRUE(d.dirty());
|
||||
std::string why;
|
||||
TEST_ASSERT_FALSE(d.rewriteStart(why));
|
||||
TEST_ASSERT_FALSE(d.journal(why));
|
||||
TEST_ASSERT_EQUAL_size_t(0, card.files.size());
|
||||
d.setPath("/notes/a-list.txt");
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
TEST_ASSERT_EQUAL_STRING("A list", card.files["/notes/a-list.txt"].c_str());
|
||||
type(d, "!");
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
TEST_ASSERT_EQUAL_STRING("A list!", card.files["/notes/a-list.txt"].c_str());
|
||||
}
|
||||
|
||||
void test_a_big_file_opens_as_a_window() {
|
||||
FakeCard card;
|
||||
card.files[kNote] = lines(0, 20000); // 240 KB
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
TEST_ASSERT_EQUAL_STRING("", d.open(kNote).c_str());
|
||||
TEST_ASSERT_TRUE(d.windowed());
|
||||
TEST_ASSERT_EQUAL_UINT32(240000, d.size());
|
||||
TEST_ASSERT_TRUE(d.text().size() <= NoteText::kMaxBytes / 2 + 200);
|
||||
TEST_ASSERT_EQUAL_STRING("line 000000", d.text().rows()[0].c_str());
|
||||
TEST_ASSERT_FALSE(d.wantsRewrite());
|
||||
TEST_ASSERT_FALSE(d.dirty());
|
||||
}
|
||||
|
||||
void test_walking_down_and_back_up_through_windows() {
|
||||
FakeCard card;
|
||||
card.files[kNote] = lines(0, 5000);
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
for (int i = 1; i <= 3000; i++) {
|
||||
d.text().down();
|
||||
settle(d);
|
||||
if (i % 500 == 0) {
|
||||
TEST_ASSERT_EQUAL_UINT32(static_cast<uint32_t>(i) * kLine, d.cursor());
|
||||
TEST_ASSERT_EQUAL_STRING(lines(i, 1).substr(0, 11).c_str(), currentRow(d).c_str());
|
||||
TEST_ASSERT_EQUAL_INT(kRows - 1, d.text().cursorRow()); // the screen didn't jump when the window moved
|
||||
}
|
||||
}
|
||||
for (int i = 2999; i >= 0; i--) {
|
||||
d.text().up();
|
||||
settle(d);
|
||||
if (i % 500 == 0) {
|
||||
TEST_ASSERT_EQUAL_UINT32(static_cast<uint32_t>(i) * kLine, d.cursor());
|
||||
TEST_ASSERT_EQUAL_STRING(lines(i, 1).substr(0, 11).c_str(), currentRow(d).c_str());
|
||||
}
|
||||
}
|
||||
TEST_ASSERT_FALSE(d.dirty()); // looking isn't editing
|
||||
TEST_ASSERT_EQUAL_size_t(1, card.files.size()); // and writes nothing
|
||||
}
|
||||
|
||||
void test_start_and_end() {
|
||||
FakeCard card;
|
||||
card.files[kNote] = lines(0, 5000) + "the end";
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
std::string why;
|
||||
TEST_ASSERT_TRUE(d.jump(d.size(), why));
|
||||
TEST_ASSERT_EQUAL_UINT32(d.size(), d.cursor());
|
||||
TEST_ASSERT_EQUAL_STRING("the end", currentRow(d).c_str());
|
||||
TEST_ASSERT_EQUAL_INT(kRows - 1, d.text().cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(99, d.percent());
|
||||
TEST_ASSERT_TRUE(d.jump(0, why));
|
||||
TEST_ASSERT_EQUAL_UINT32(0, d.cursor());
|
||||
TEST_ASSERT_EQUAL_STRING("line 000000", currentRow(d).c_str());
|
||||
}
|
||||
|
||||
void test_an_edit_in_the_middle_is_rewritten_into_the_file() {
|
||||
FakeCard card;
|
||||
std::string was = lines(0, 20000);
|
||||
card.files[kNote] = was;
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
std::string why;
|
||||
TEST_ASSERT_TRUE(d.jump(120000, why));
|
||||
type(d, "HERE ");
|
||||
TEST_ASSERT_TRUE(d.jump(0, why)); // the changed window goes to the side file
|
||||
TEST_ASSERT_TRUE(card.files.count(std::string(kNote) + ".edit"));
|
||||
TEST_ASSERT_EQUAL_STRING(was.c_str(), card.files[kNote].c_str()); // the file isn't touched until the rewrite
|
||||
type(d, "TOP ");
|
||||
TEST_ASSERT_TRUE(d.filePending());
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
std::string want = "TOP " + was.substr(0, 120000) + "HERE " + was.substr(120000);
|
||||
TEST_ASSERT_TRUE(want == card.files[kNote]);
|
||||
TEST_ASSERT_EQUAL_size_t(1, card.files.size());
|
||||
TEST_ASSERT_FALSE(d.filePending());
|
||||
TEST_ASSERT_FALSE(d.dirty());
|
||||
// And it goes on from there.
|
||||
TEST_ASSERT_TRUE(d.jump(d.size(), why));
|
||||
type(d, "END");
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
TEST_ASSERT_TRUE(want + "END" == card.files[kNote]);
|
||||
}
|
||||
|
||||
void test_typing_never_fills_the_note() {
|
||||
FakeCard card;
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.setPath("/notes/long.txt");
|
||||
std::string want;
|
||||
for (int i = 0; i < 4000; i++) { // 48 KB, three windows' worth
|
||||
std::string l = lines(i, 1);
|
||||
for (char c : l) {
|
||||
if (!d.text().insert(static_cast<uint8_t>(c))) TEST_FAIL_MESSAGE("the window was full");
|
||||
if (d.wantsMove()) {
|
||||
std::string why;
|
||||
if (d.path().empty() || !d.windowed()) TEST_ASSERT_TRUE(rewrite(d)); // what the editor does: a file first
|
||||
TEST_ASSERT_TRUE_MESSAGE(d.move(why), why.c_str());
|
||||
}
|
||||
}
|
||||
want += l;
|
||||
}
|
||||
TEST_ASSERT_TRUE(d.windowed());
|
||||
TEST_ASSERT_EQUAL_UINT32(48000, d.size());
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
TEST_ASSERT_TRUE(want == card.files["/notes/long.txt"]);
|
||||
}
|
||||
|
||||
void test_a_journal_is_picked_up_after_a_power_cut() {
|
||||
FakeCard card;
|
||||
std::string was = lines(0, 20000);
|
||||
card.files[kNote] = was;
|
||||
std::string why;
|
||||
{
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
d.jump(60000, why);
|
||||
type(d, "one ");
|
||||
TEST_ASSERT_TRUE(d.journal(why));
|
||||
TEST_ASSERT_FALSE(d.dirty());
|
||||
TEST_ASSERT_TRUE(d.filePending());
|
||||
d.jump(180000, why);
|
||||
type(d, "two ");
|
||||
TEST_ASSERT_TRUE(d.journal(why));
|
||||
type(d, "never saved");
|
||||
} // the power goes
|
||||
TEST_ASSERT_EQUAL_STRING(was.c_str(), card.files[kNote].c_str());
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
std::string told;
|
||||
TEST_ASSERT_EQUAL_STRING("", d.open(kNote, &told).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("Your unsaved changes are back", told.c_str());
|
||||
TEST_ASSERT_EQUAL_UINT32(240008, d.size());
|
||||
TEST_ASSERT_EQUAL_UINT32(180004, d.cursor()); // where it was
|
||||
TEST_ASSERT_TRUE(d.filePending());
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
TEST_ASSERT_TRUE(was.substr(0, 60000) + "one " + was.substr(60000, 119996) + "two " + was.substr(179996) == card.files[kNote]);
|
||||
TEST_ASSERT_EQUAL_size_t(1, card.files.size());
|
||||
}
|
||||
|
||||
void test_a_cut_at_any_byte_keeps_the_note_or_the_last_save() {
|
||||
std::string was = lines(0, 9000); // 108 KB
|
||||
std::string saved1 = was.substr(0, 50000) + "first " + was.substr(50000);
|
||||
std::string saved2 = "second " + saved1;
|
||||
int outcomes[3] = {0, 0, 0};
|
||||
for (int64_t budget = 0; budget < 240000; budget += 997) {
|
||||
FakeCard card;
|
||||
card.files[kNote] = was;
|
||||
std::string why;
|
||||
bool journaled1 = false, journaled2 = false, rewritten = false;
|
||||
{
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
card.budget = budget;
|
||||
d.jump(50000, why);
|
||||
for (char c : std::string("first ")) d.text().insert(static_cast<uint8_t>(c));
|
||||
journaled1 = d.journal(why);
|
||||
if (journaled1 && d.jump(0, why)) {
|
||||
for (char c : std::string("second ")) d.text().insert(static_cast<uint8_t>(c));
|
||||
journaled2 = d.journal(why);
|
||||
if (journaled2) rewritten = rewrite(d);
|
||||
}
|
||||
}
|
||||
card.powerBack();
|
||||
// What the Notes list does with a temporary file that has lost its note.
|
||||
if (!card.files.count(kNote) && card.files.count(std::string(kNote) + ".tmp")) card.rename(std::string(kNote) + ".tmp", kNote);
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
TEST_ASSERT_EQUAL_STRING("", d.open(kNote).c_str());
|
||||
TEST_ASSERT_TRUE(rewrite(d) || !d.dirty());
|
||||
if (d.filePending()) TEST_ASSERT_TRUE(rewrite(d));
|
||||
const std::string& now = card.files[kNote];
|
||||
int which = now == was ? 0 : now == saved1 ? 1 : now == saved2 ? 2 : -1;
|
||||
char msg[64];
|
||||
std::snprintf(msg, sizeof msg, "budget %ld", static_cast<long>(budget));
|
||||
TEST_ASSERT_TRUE_MESSAGE(which >= 0, msg); // never anything else
|
||||
if (rewritten) TEST_ASSERT_EQUAL_INT_MESSAGE(2, which, msg); // what was said to be saved is there
|
||||
else if (journaled2) TEST_ASSERT_EQUAL_INT_MESSAGE(2, which, msg);
|
||||
else if (journaled1) TEST_ASSERT_TRUE_MESSAGE(which >= 1, msg);
|
||||
TEST_ASSERT_EQUAL_size_t_MESSAGE(1, card.files.size(), msg); // and nothing is left lying about
|
||||
outcomes[which]++;
|
||||
}
|
||||
TEST_ASSERT_TRUE(outcomes[0] > 0 && outcomes[1] > 0 && outcomes[2] > 0);
|
||||
}
|
||||
|
||||
void test_a_file_changed_elsewhere_sets_the_edits_aside() {
|
||||
FakeCard card;
|
||||
card.files[kNote] = lines(0, 20000);
|
||||
std::string why;
|
||||
{
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
d.jump(60000, why);
|
||||
type(d, "typed ");
|
||||
TEST_ASSERT_TRUE(d.journal(why));
|
||||
}
|
||||
card.files[kNote] = lines(7, 100); // someone put another file there
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
std::string told;
|
||||
TEST_ASSERT_EQUAL_STRING("", d.open(kNote, &told).c_str());
|
||||
TEST_ASSERT_TRUE(told.find(".edit.lost") != std::string::npos);
|
||||
TEST_ASSERT_TRUE(card.files.count(std::string(kNote) + ".edit.lost"));
|
||||
TEST_ASSERT_FALSE(card.files.count(std::string(kNote) + ".edit"));
|
||||
TEST_ASSERT_TRUE(card.files[std::string(kNote) + ".edit.lost"].find("typed ") != std::string::npos); // it can be read
|
||||
TEST_ASSERT_EQUAL_UINT32(1200, d.size());
|
||||
TEST_ASSERT_FALSE(d.filePending());
|
||||
}
|
||||
|
||||
void test_crlf_becomes_lf_everywhere() {
|
||||
FakeCard card;
|
||||
std::string crlf, lf;
|
||||
for (int i = 0; i < 6000; i++) {
|
||||
std::string l = lines(i, 1);
|
||||
lf += l;
|
||||
crlf += l.substr(0, 11) + "\r\n";
|
||||
}
|
||||
card.files[kNote] = crlf;
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
for (int i = 1; i <= 1500; i++) { // through several windows, looking only
|
||||
d.text().down();
|
||||
settle(d);
|
||||
}
|
||||
TEST_ASSERT_EQUAL_STRING("line 001500", currentRow(d).c_str());
|
||||
TEST_ASSERT_EQUAL_INT(0, d.text().cursorCol());
|
||||
type(d, "x");
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
std::string want = lf;
|
||||
want.insert(1500 * kLine, "x");
|
||||
TEST_ASSERT_TRUE(want == card.files[kNote]);
|
||||
}
|
||||
|
||||
void test_windows_never_cut_a_character() {
|
||||
FakeCard card;
|
||||
std::string text;
|
||||
while (text.size() < 80000) text += "\xC3\xA9\xE2\x82\xAC\xF0\x9F\x98\x80"; // é, €, a face: no space, no newline
|
||||
card.files[kNote] = text;
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
std::string why;
|
||||
for (uint32_t to : {30001u, 12345u, 79990u, 40003u, 5u}) {
|
||||
TEST_ASSERT_TRUE(d.jump(to, why));
|
||||
const std::string& w = d.text().text();
|
||||
TEST_ASSERT_TRUE((static_cast<uint8_t>(w[0]) & 0xC0) != 0x80);
|
||||
for (size_t i = 0; i < w.size();) { // whole characters, to the last
|
||||
uint8_t c = static_cast<uint8_t>(w[i]);
|
||||
size_t n = c >= 0xF0 ? 4 : c >= 0xE0 ? 3 : c >= 0xC0 ? 2 : 1;
|
||||
TEST_ASSERT_TRUE(c < 0x80 || c >= 0xC0);
|
||||
TEST_ASSERT_TRUE(i + n <= w.size());
|
||||
i += n;
|
||||
}
|
||||
TEST_ASSERT_TRUE((static_cast<uint8_t>(w[d.text().cursor()]) & 0xC0) != 0x80 || d.text().cursor() == w.size());
|
||||
type(d, "a");
|
||||
}
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
TEST_ASSERT_EQUAL_size_t(text.size() + 5, card.files[kNote].size());
|
||||
}
|
||||
|
||||
void test_random_edits_match_a_plain_string() {
|
||||
std::mt19937 rng(47);
|
||||
for (int round = 0; round < 6; round++) {
|
||||
FakeCard card;
|
||||
std::string model;
|
||||
while (model.size() < 30000u + 20000u * static_cast<unsigned>(round)) {
|
||||
int words = 1 + static_cast<int>(rng() % 30);
|
||||
for (int w = 0; w < words; w++) model += std::string(1 + rng() % 9, static_cast<char>('a' + rng() % 26)) + " ";
|
||||
model += rng() % 4 ? "\n" : "\n\n";
|
||||
}
|
||||
card.files[kNote] = model;
|
||||
std::string saved = model; // what a power cut would leave
|
||||
NoteDocument* d = new NoteDocument(card, kCols, kRows);
|
||||
d->open(kNote);
|
||||
std::string why;
|
||||
for (int step = 0; step < 6000; step++) {
|
||||
unsigned op = rng() % 100;
|
||||
NoteText& t = d->text();
|
||||
if (op < 40) {
|
||||
char c = rng() % 8 ? static_cast<char>('A' + rng() % 26) : '\n';
|
||||
uint32_t at = d->cursor();
|
||||
TEST_ASSERT_TRUE(t.insert(static_cast<uint8_t>(c)));
|
||||
model.insert(at, 1, c);
|
||||
} else if (op < 50) {
|
||||
uint32_t at = d->cursor();
|
||||
t.backspace();
|
||||
if (at > 0 && d->cursor() == at - 1) model.erase(at - 1, 1);
|
||||
else TEST_ASSERT_EQUAL_UINT32(at, d->cursor());
|
||||
} else if (op < 60) t.left();
|
||||
else if (op < 70) t.right();
|
||||
else if (op < 78) t.up();
|
||||
else if (op < 86) t.down();
|
||||
else if (op < 90) t.pageUp();
|
||||
else if (op < 94) t.pageDown();
|
||||
else if (op < 96) TEST_ASSERT_TRUE(d->jump(rng() % (d->size() + 1), why));
|
||||
else if (op < 98) {
|
||||
if (d->dirty()) {
|
||||
TEST_ASSERT_TRUE(d->wantsRewrite() ? rewrite(*d) : d->journal(why));
|
||||
saved = model;
|
||||
}
|
||||
} else if (op == 98) {
|
||||
TEST_ASSERT_TRUE(rewrite(*d));
|
||||
saved = model;
|
||||
TEST_ASSERT_TRUE(model == card.files[kNote]);
|
||||
} else { // the power goes, and comes back
|
||||
delete d;
|
||||
d = new NoteDocument(card, kCols, kRows);
|
||||
TEST_ASSERT_EQUAL_STRING("", d->open(kNote).c_str());
|
||||
model = saved;
|
||||
}
|
||||
settle(*d);
|
||||
TEST_ASSERT_EQUAL_UINT32(static_cast<uint32_t>(model.size()), d->size());
|
||||
// The window is the note's text at its place.
|
||||
TEST_ASSERT_TRUE(d->cursor() <= model.size());
|
||||
uint32_t start = d->cursor() - static_cast<uint32_t>(d->text().cursor());
|
||||
TEST_ASSERT_TRUE(model.compare(start, d->text().size(), d->text().text()) == 0);
|
||||
}
|
||||
TEST_ASSERT_TRUE(rewrite(*d));
|
||||
TEST_ASSERT_TRUE(model == card.files[kNote]);
|
||||
TEST_ASSERT_EQUAL_size_t(1, card.files.size());
|
||||
delete d;
|
||||
}
|
||||
}
|
||||
|
||||
void test_a_full_card_and_a_missing_file() {
|
||||
FakeCard card;
|
||||
card.files[kNote] = lines(0, 20000);
|
||||
card.free = 1000;
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
TEST_ASSERT_TRUE(d.open(kNote).find("Not enough room") != std::string::npos);
|
||||
TEST_ASSERT_TRUE(d.open("/notes/none.txt").find("refused") != std::string::npos);
|
||||
card.files["/notes/small.txt"] = "small";
|
||||
TEST_ASSERT_EQUAL_STRING("", d.open("/notes/small.txt").c_str()); // a small one still opens
|
||||
type(d, "x");
|
||||
std::string why;
|
||||
TEST_ASSERT_FALSE(d.rewriteStart(why));
|
||||
TEST_ASSERT_EQUAL_STRING("the card is full", why.c_str());
|
||||
TEST_ASSERT_TRUE(d.dirty());
|
||||
}
|
||||
|
||||
void test_the_side_file_and_the_piece_list_ask_for_a_rewrite() {
|
||||
FakeCard card;
|
||||
card.files[kNote] = lines(0, 20000);
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
std::string why;
|
||||
int saves = 0;
|
||||
while (!d.wantsRewrite() && saves < 1000) {
|
||||
type(d, "x");
|
||||
TEST_ASSERT_TRUE(d.journal(why));
|
||||
saves++;
|
||||
}
|
||||
TEST_ASSERT_TRUE(saves > 50 && saves < 300); // about 1 MB of 8 KB windows
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
TEST_ASSERT_FALSE(d.wantsRewrite());
|
||||
TEST_ASSERT_EQUAL_size_t(240000 + static_cast<size_t>(saves), card.files[kNote].size());
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_a_small_note_is_all_window);
|
||||
RUN_TEST(test_a_new_note_has_no_file_until_it_is_rewritten);
|
||||
RUN_TEST(test_a_big_file_opens_as_a_window);
|
||||
RUN_TEST(test_walking_down_and_back_up_through_windows);
|
||||
RUN_TEST(test_start_and_end);
|
||||
RUN_TEST(test_an_edit_in_the_middle_is_rewritten_into_the_file);
|
||||
RUN_TEST(test_typing_never_fills_the_note);
|
||||
RUN_TEST(test_a_journal_is_picked_up_after_a_power_cut);
|
||||
RUN_TEST(test_a_cut_at_any_byte_keeps_the_note_or_the_last_save);
|
||||
RUN_TEST(test_a_file_changed_elsewhere_sets_the_edits_aside);
|
||||
RUN_TEST(test_crlf_becomes_lf_everywhere);
|
||||
RUN_TEST(test_windows_never_cut_a_character);
|
||||
RUN_TEST(test_random_edits_match_a_plain_string);
|
||||
RUN_TEST(test_a_full_card_and_a_missing_file);
|
||||
RUN_TEST(test_the_side_file_and_the_piece_list_ask_for_a_rewrite);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "png_reader.h"
|
||||
#include "png_rgb332.h"
|
||||
#include "pngs.h"
|
||||
|
||||
using namespace roro::files;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
namespace {
|
||||
ImageRead from(const std::string& bytes) {
|
||||
return [&bytes](uint32_t at, uint8_t* into, size_t len) -> size_t {
|
||||
if (at >= bytes.size()) return 0;
|
||||
size_t n = std::min(len, bytes.size() - at);
|
||||
std::memcpy(into, bytes.data() + at, n);
|
||||
return n;
|
||||
};
|
||||
}
|
||||
template <size_t N>
|
||||
std::string str(const uint8_t (&a)[N]) { return std::string(reinterpret_cast<const char*>(a), N); }
|
||||
|
||||
struct Picture {
|
||||
int w, h;
|
||||
std::vector<int> rgb; // -1: never drawn
|
||||
Picture(int width, int height) : w(width), h(height), rgb(static_cast<size_t>(width * height), -1) {}
|
||||
ImagePixels sink() {
|
||||
return [this](int x, int y, int count, const uint8_t* p) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
TEST_ASSERT_TRUE(x + i >= 0 && x + i < w && y >= 0 && y < h);
|
||||
rgb[static_cast<size_t>(y * w + x + i)] = (p[i * 3] << 16) | (p[i * 3 + 1] << 8) | p[i * 3 + 2];
|
||||
}
|
||||
};
|
||||
}
|
||||
int at(int x, int y) const { return rgb[static_cast<size_t>(y * w + x)]; }
|
||||
};
|
||||
|
||||
Picture decode(const std::string& file, int w, int h) {
|
||||
Picture p(w, h);
|
||||
TEST_ASSERT_EQUAL_STRING("", readPng(from(file), static_cast<uint32_t>(file.size()), p.sink()).c_str());
|
||||
return p;
|
||||
}
|
||||
int colour(int x, int y) { return (((x * 9 + y) % 256) << 16) | (((y * 17) % 256) << 8) | ((x * x + y * y) % 256); }
|
||||
int grey(int v) { return (v << 16) | (v << 8) | v; }
|
||||
} // namespace
|
||||
|
||||
void test_rgb() {
|
||||
Picture p = decode(str(kPngRgb), 96, 64);
|
||||
for (int y = 0; y < 64; y++)
|
||||
for (int x = 0; x < 96; x++) TEST_ASSERT_EQUAL_HEX32(colour(x, y), p.at(x, y));
|
||||
}
|
||||
|
||||
void test_a_noisy_picture_with_every_kind_of_row() {
|
||||
Picture p = decode(str(kPngPhoto), 64, 48);
|
||||
for (int i = 0; i < 64 * 48; i++)
|
||||
TEST_ASSERT_EQUAL_HEX32((kPngPhotoPixels[i * 3] << 16) | (kPngPhotoPixels[i * 3 + 1] << 8) | kPngPhotoPixels[i * 3 + 2], p.rgb[static_cast<size_t>(i)]);
|
||||
}
|
||||
|
||||
void test_stored_not_compressed() {
|
||||
Picture p = decode(str(kPngStored), 16, 12);
|
||||
for (int y = 0; y < 12; y++)
|
||||
for (int x = 0; x < 16; x++) TEST_ASSERT_EQUAL_HEX32(colour(x, y), p.at(x, y));
|
||||
}
|
||||
|
||||
void test_references_far_back() {
|
||||
Picture p = decode(str(kPngFarBack), 120, 90);
|
||||
uint32_t total = 0;
|
||||
for (int v : p.rgb) {
|
||||
TEST_ASSERT_TRUE(v >= 0);
|
||||
total += static_cast<uint32_t>((v >> 16) + ((v >> 8) & 255) + (v & 255));
|
||||
}
|
||||
TEST_ASSERT_EQUAL_UINT32((kPngFarBackSum[0] << 24) | (kPngFarBackSum[1] << 16) | (kPngFarBackSum[2] << 8) | kPngFarBackSum[3], total);
|
||||
for (int x = 0; x < 120; x++) TEST_ASSERT_EQUAL_HEX32(p.at(x, 0) + 1, p.at(x, 60)); // the same row, its blue one more
|
||||
}
|
||||
|
||||
void test_transparent_pixels_are_left_out() {
|
||||
Picture p = decode(str(kPngRgba), 16, 12);
|
||||
for (int y = 0; y < 12; y++)
|
||||
for (int x = 0; x < 16; x++) TEST_ASSERT_EQUAL_HEX32((x + y) % 5 == 0 ? -1 : colour(x, y), p.at(x, y));
|
||||
Picture g = decode(str(kPngGrayAlpha), 16, 12);
|
||||
for (int y = 0; y < 12; y++)
|
||||
for (int x = 0; x < 16; x++) TEST_ASSERT_EQUAL_HEX32(x % 2 ? grey((x * 16 + y * 3) % 256) : -1, g.at(x, y));
|
||||
}
|
||||
|
||||
void test_palettes() {
|
||||
auto entry = [](int i) { return (((i * 7) % 256) << 16) | (((255 - i * 3) % 256) << 8) | ((i * 13 + 40) % 256); };
|
||||
Picture p = decode(str(kPngPalette), 16, 12);
|
||||
for (int y = 0; y < 12; y++)
|
||||
for (int x = 0; x < 16; x++) {
|
||||
int i = (x * 3 + y * 5) % 7;
|
||||
TEST_ASSERT_EQUAL_HEX32(i == 3 ? -1 : entry(i), p.at(x, y)); // index 3 is the transparent one
|
||||
}
|
||||
Picture q = decode(str(kPngPalette4), 17, 5); // four bits a pixel, a row that ends in half a byte
|
||||
for (int y = 0; y < 5; y++)
|
||||
for (int x = 0; x < 17; x++) TEST_ASSERT_EQUAL_HEX32(entry((x + y * 3) % 13), q.at(x, y));
|
||||
}
|
||||
|
||||
void test_greys() {
|
||||
Picture g = decode(str(kPngGray), 16, 12);
|
||||
Picture one = decode(str(kPngGray1), 19, 7);
|
||||
Picture deep = decode(str(kPngGray16), 16, 12);
|
||||
for (int y = 0; y < 12; y++)
|
||||
for (int x = 0; x < 16; x++) {
|
||||
TEST_ASSERT_EQUAL_HEX32(grey((x * 16 + y * 3) % 256), g.at(x, y));
|
||||
TEST_ASSERT_EQUAL_HEX32(grey(((x * 4000 + y * 300) % 65536) >> 8), deep.at(x, y));
|
||||
}
|
||||
for (int y = 0; y < 7; y++)
|
||||
for (int x = 0; x < 19; x++) TEST_ASSERT_EQUAL_HEX32((x + y) % 2 ? 0xFFFFFF : 0, one.at(x, y));
|
||||
}
|
||||
|
||||
void test_one_of_the_firmwares_own_screenshots() {
|
||||
std::string file;
|
||||
roro::png::Rgb332Writer writer(40, 9, [&](const uint8_t* d, size_t n) {
|
||||
file.append(reinterpret_cast<const char*>(d), n);
|
||||
return true;
|
||||
});
|
||||
writer.begin();
|
||||
std::vector<uint8_t> row(40);
|
||||
for (int y = 0; y < 9; y++) {
|
||||
for (int x = 0; x < 40; x++) row[static_cast<size_t>(x)] = static_cast<uint8_t>(x * 6 + y);
|
||||
writer.row(row.data());
|
||||
}
|
||||
writer.end();
|
||||
Picture p = decode(file, 40, 9);
|
||||
for (int y = 0; y < 9; y++)
|
||||
for (int x = 0; x < 40; x++) {
|
||||
int v = (x * 6 + y) & 0xFF;
|
||||
TEST_ASSERT_EQUAL_HEX32((((v >> 5) * 255 / 7) << 16) | ((((v >> 2) & 7) * 255 / 7) << 8) | ((v & 3) * 85), p.at(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
void test_rows_can_be_left_out_and_the_end_cut_off() {
|
||||
std::string file = str(kPngRgb);
|
||||
Picture p(96, 64);
|
||||
int reads = 0;
|
||||
ImageRead counted = [&](uint32_t at, uint8_t* into, size_t len) {
|
||||
reads++;
|
||||
return from(file)(at, into, len);
|
||||
};
|
||||
TEST_ASSERT_EQUAL_STRING("", readPng(counted, static_cast<uint32_t>(file.size()), p.sink(), [](int y) { return y % 2 == 0; }, [](int y) { return y >= 10; }).c_str());
|
||||
TEST_ASSERT_EQUAL_HEX32(colour(5, 8), p.at(5, 8));
|
||||
TEST_ASSERT_EQUAL_INT(-1, p.at(5, 9)); // left out
|
||||
TEST_ASSERT_EQUAL_INT(-1, p.at(5, 10)); // and nothing from the tenth on
|
||||
Picture all(96, 64);
|
||||
int allReads = 0;
|
||||
ImageRead countAll = [&](uint32_t at, uint8_t* into, size_t len) {
|
||||
allReads++;
|
||||
return from(file)(at, into, len);
|
||||
};
|
||||
readPng(countAll, static_cast<uint32_t>(file.size()), all.sink());
|
||||
TEST_ASSERT_TRUE(reads < allReads / 2); // it stopped reading the file too
|
||||
}
|
||||
|
||||
void test_what_it_refuses() {
|
||||
Picture p(96, 64);
|
||||
std::string interlaced = str(kPngRgb);
|
||||
interlaced[28] = 1;
|
||||
TEST_ASSERT_EQUAL_STRING("An interlaced PNG can't be shown", readPng(from(interlaced), static_cast<uint32_t>(interlaced.size()), p.sink()).c_str());
|
||||
std::string cut = str(kPngRgb).substr(0, 2000);
|
||||
TEST_ASSERT_EQUAL_STRING("This PNG is cut short", readPng(from(cut), static_cast<uint32_t>(cut.size()), p.sink()).c_str());
|
||||
std::string notPng = "GIF89a and then some more bytes that are not a PNG";
|
||||
TEST_ASSERT_EQUAL_STRING("This PNG is damaged", readPng(from(notPng), static_cast<uint32_t>(notPng.size()), p.sink()).c_str());
|
||||
// Any byte changed: an answer, never a crash or a pixel outside the picture (the sink checks).
|
||||
std::string file = str(kPngPhoto);
|
||||
for (size_t i = 8; i < file.size(); i += 7) {
|
||||
std::string bad = file;
|
||||
bad[i] = static_cast<char>(bad[i] ^ 0xA5);
|
||||
Picture q(64, 48);
|
||||
readPng(from(bad), static_cast<uint32_t>(bad.size()), q.sink());
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_rgb);
|
||||
RUN_TEST(test_a_noisy_picture_with_every_kind_of_row);
|
||||
RUN_TEST(test_stored_not_compressed);
|
||||
RUN_TEST(test_references_far_back);
|
||||
RUN_TEST(test_transparent_pixels_are_left_out);
|
||||
RUN_TEST(test_palettes);
|
||||
RUN_TEST(test_greys);
|
||||
RUN_TEST(test_one_of_the_firmwares_own_screenshots);
|
||||
RUN_TEST(test_rows_can_be_left_out_and_the_end_cut_off);
|
||||
RUN_TEST(test_what_it_refuses);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||