mirror of
https://github.com/crosspoint-reader/crosspoint-reader.git
synced 2026-08-13 14:21:57 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf9fea5923 |
No files matched your search
@@ -31,5 +31,4 @@ lib/EpdFont/scripts/output/
|
||||
/dependencies.lock
|
||||
/sdkconfig.*
|
||||
sdkconfig.sticky
|
||||
sdkconfig.x4pro
|
||||
flake.*
|
||||
@@ -1 +1 @@
|
||||
.skills/SKILL.md
|
||||
/home/urit/Documents/CrossPoint/crosspoint-reader/.skills/SKILL.md
|
||||
@@ -136,7 +136,7 @@ Conversion runs the firmware repo's `lib/EpdFont/scripts/fontconvert_sdcard.py`
|
||||
- [Web server endpoints](./docs/webserver-endpoints.md)
|
||||
- [Project scope](./SCOPE.md)
|
||||
- [Contributing docs](./docs/contributing/README.md)
|
||||
- [Touch and UI development](./docs/contributing/touch-and-ui.md) - how to build new screens on the FreeInkUI activity bases (UiListActivity and friends), plus build envs for the non-Xteink touch devices
|
||||
- [Touch and UI development](./docs/contributing/touch-and-ui.md) - FreeInkUI components for new screens, the touch bridge for existing ones, and build envs for the non-Xteink touch devices
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,88 +1,23 @@
|
||||
# Touch and UI Development
|
||||
|
||||
CrossPoint runs on touch devices (Seeed Sticky, M5Paper, LilyGo T5) alongside the button-only Xteink X3/X4. Every screen must work with both input styles.
|
||||
CrossPoint now runs on touch devices (Seeed Sticky, M5Paper, LilyGo T5) alongside the button-only Xteink X3/X4. Every screen must work with both input styles. There are two supported ways to get there:
|
||||
|
||||
**There is one supported way to build a new screen: FreeInkUI, hosted through the firmware base classes below.** Touch hit-testing, tap highlighting, long-press, swipe scrolling, and button focus navigation all come from the shared stack; you never hand-roll coordinate math.
|
||||
1. **New screens: build them with FreeInkUI components.** Touch hit-testing, tap highlighting, long-press, and button focus navigation come with the component; you never hand-roll coordinate math.
|
||||
2. **Existing screens and in-flight features: use the MappedInputManager touch bridge.** A small set of helpers adds tap/hold/swipe support to hand-rolled rendering without restructuring the activity.
|
||||
|
||||
The old bridge helpers (`rowTouch`, `colTouch`, `wasTapInRect`, manual rect `contains()` checks) are legacy. They survive only for the two remaining hand-rolled surfaces (the theme-driven home screen and the reader page) and must not appear in new code. PRs that add new uses will be asked to convert.
|
||||
If you are starting a new activity, use FreeInkUI. If you have a feature branch with a hand-rolled screen already working on buttons, use the bridge; do not rewrite mid-flight.
|
||||
|
||||
---
|
||||
|
||||
## Picking the right host
|
||||
## Path 1: New screens with FreeInkUI
|
||||
|
||||
| Your screen is... | Use | In-tree reference |
|
||||
|---|---|---|
|
||||
| A single list of rows | subclass `UiListActivity` | [`LanguageSelectActivity`](../../src/activities/settings/LanguageSelectActivity.cpp) (minimal), [`RecentBooksActivity`](../../src/activities/home/RecentBooksActivity.cpp) (long-press) |
|
||||
| Tabbed lists | subclass `UiTabListActivity` | [`SettingsActivity`](../../src/activities/settings/SettingsActivity.cpp) |
|
||||
| Custom FUI layout (sliders, prompts, state machines) | inherit `UiAppHost` directly | [`EpubReaderPercentSelectionActivity`](../../src/activities/reader/EpubReaderPercentSelectionActivity.cpp), [`WifiSelectionActivity`](../../src/activities/network/WifiSelectionActivity.cpp) |
|
||||
| A modal picker or confirm inside a legacy activity | `OptionPopup` (or push `ConfirmationActivity`) | [`OtaUpdateActivity`](../../src/activities/settings/OtaUpdateActivity.cpp) |
|
||||
| A Yes/No prompt inside a FUI screen | build `fui::optionDialog` into the screen | `WifiSelectionActivity::buildPromptDialog` |
|
||||
FreeInkUI (`freeink-sdk/libs/ui/FreeInkUI`, namespace `freeink::ui`) is an immediate-mode component library. The core idea:
|
||||
|
||||
All four paths route input through the same SDK interaction table, so touch and physical buttons fire the same actions with no per-screen coordinate code.
|
||||
- While a component renders, it registers its tappable areas ("hit rects") into the frame's interaction buffer via `Frame::hit(rect, action, value, inputMask, state)`.
|
||||
- Each loop you build an `InputSnapshot` from the input manager and route it against that buffer. If a tap (or mapped button press) lands in a registered rect, the routed `ActionEvent` tells you which action fired and with what value.
|
||||
- Components query `frame.stateFor(action, value)` while painting, so touch-down highlight and focus states render correctly without any per-activity code.
|
||||
|
||||
## The hosting stack
|
||||
|
||||
`UiAppHost` ([src/components/UiAppHost.h](../../src/components/UiAppHost.h)) owns what every FUI screen shares: the font-bound render target, the `FreeInkApp`, and the `uiReady` handshake that lets the loop task route touch snapshots against the interaction table the render task rebuilds. Never re-implement that handshake; it is a cross-task protocol and lives in exactly one place.
|
||||
|
||||
`UiListActivity` ([src/activities/UiListActivity.h](../../src/activities/UiListActivity.h)) layers the list protocol on top: swipes scroll the viewport without moving the selection, buttons move the selection and pull the viewport along (`fui::ListNav`), plus the render skeleton (header chrome, app, footer hints). `UiTabListActivity` is the tab-bar sibling; its selection ring puts the tab bar at position 0 and the rows after it.
|
||||
|
||||
### New list screen skeleton
|
||||
|
||||
Subclasses supply the data; the base owns the loop. The whole contract:
|
||||
|
||||
```cpp
|
||||
class MyListActivity final : public UiListActivity {
|
||||
public:
|
||||
MyListActivity(GfxRenderer& r, MappedInputManager& in) : UiListActivity("MyList", r, in) {}
|
||||
|
||||
private:
|
||||
std::vector<Entry> entries; // the activity's data, loaded in onEnter
|
||||
std::vector<fui::ListItem> rows; // activity-owned row cache: rebuilt only when entries changes, reused every render
|
||||
|
||||
int listCount() const override { return entries.size(); }
|
||||
const char* headerTitle() const override { return tr(STR_MY_TITLE); }
|
||||
|
||||
void onEnter() override {
|
||||
UiListActivity::onEnter();
|
||||
entries = /* ... load from wherever ... */;
|
||||
rebuildRows();
|
||||
}
|
||||
|
||||
// Called whenever entries changes (here, only onEnter; a mutable list would
|
||||
// also call this after any add/remove). NOT called from buildScreen().
|
||||
void rebuildRows() {
|
||||
rows.clear();
|
||||
rows.reserve(entries.size());
|
||||
// ... one fui::ListItem per entry (label, actionValue = index) ...
|
||||
}
|
||||
|
||||
void buildScreen(UiScreen& screen) override {
|
||||
// set content margin from the theme safe area, then:
|
||||
fui::ListProps props;
|
||||
props.items = rows.data();
|
||||
props.count = rows.size();
|
||||
props.action = ACTION_ROW;
|
||||
props.inputMask = fui::InputTouch; // physical buttons stay in the base loop
|
||||
syncListViewport(screen, props); // selection/viewport handoff, always right before list()
|
||||
screen.list(props);
|
||||
}
|
||||
|
||||
void activateIndex(int index) override {
|
||||
app.clearTapFlash(); // leaving the screen: a lingering flash would gray the next render
|
||||
// ... open the thing ...
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
See [`FileBrowserActivity`](../../src/activities/home/FileBrowserActivity.cpp)'s `rebuildRowItems()` for this pattern applied to a directory listing that can run into the hundreds of entries, and `LanguageSelectActivity.cpp` for the rest of the skeleton (content-margin math, footer, etc.) — note that file still builds its row vector locally inside `buildScreen()`; match `FileBrowserActivity`, not that file, for the row cache. Optional overrides: `onRowLongPress(index)`, `drawFooter()`, `handleButtons()` for extra physical-button handling, and `ACTION_USER`-and-up action ids for non-row elements (register handlers in `onEnter` after the base's).
|
||||
|
||||
### Rules that apply to every FUI screen
|
||||
|
||||
- Register actions with `app.on(...)` in `onEnter` (after `resetUi()` for direct `UiAppHost` users). Handlers receive a `void* user` you cast back to the activity.
|
||||
- Handlers that leave the current screen call `app.clearTapFlash()` first.
|
||||
- Theme tokens are shared and bound by `resetUi()`; never call `app.setTheme` yourself. Metrics flow from the active UITheme through [`UIThemeTokens.h`](../../src/components/UIThemeTokens.h), including the per-board bezel insets that keep scrollbars visible.
|
||||
- `TextStyle.maxLines` defaults to 1 and truncates with an ellipsis. Set `maxLines` explicitly on any dialog headline or message that can wrap.
|
||||
- Everything stays allocation-free in steady state. A local `std::vector` inside `buildScreen()` is **not** allocation-free even with `reserve()` first: it starts at zero capacity on every call, `reserve()` allocates, and the destructor frees that storage before the call returns — real allocator work and fragmentation risk on every repaint (cursor move, tap flash, ...), not just on data changes. Build `ListItem` rows into activity-owned storage instead, reserved once when the underlying data loads (`onEnter()`/a `load*()` — see the skeleton above and `FileBrowserActivity::rebuildRowItems()`), and reused unchanged by every `buildScreen()` call. Use a fixed-capacity array (e.g. `ListItem rows[MAX]`, as `OptionPopup` and `KOReaderSyncActivity`'s action rows do) when the count is small and bounded. Do not hold FUI `props` across renders — only the row storage they point into.
|
||||
You get touch, hold highlighting, long-press, minimum touch-target sizing, and orientation-aware coordinates for free. The same action IDs fire from physical buttons, so one code path serves both input styles.
|
||||
|
||||
### Component inventory
|
||||
|
||||
@@ -98,11 +33,106 @@ All under `freeink-sdk/libs/ui/FreeInkUI/include/components/`:
|
||||
| Media | `book-card`, `cover-grid`, `cover-carousel`, `metric-card` |
|
||||
| Text | `text-field`, `text-area` |
|
||||
|
||||
One exception to "always go through a host": `KeyboardEntryActivity` drives the keyboard component with a raw `fui::Frame` and `TouchHoldRouter` because per-key hold repeat needs its own routing. If you think your screen needs that, raise it in the PR first.
|
||||
### Integration skeleton
|
||||
|
||||
The in-tree reference is [`src/activities/util/KeyboardEntryActivity.cpp`](../../src/activities/util/KeyboardEntryActivity.cpp); it drives the FreeInkUI keyboard component inside a normal Activity. The shape:
|
||||
|
||||
```cpp
|
||||
#include <FreeInkUIGfxRenderer.h>
|
||||
namespace fui = freeink::ui;
|
||||
|
||||
// Render step: draw the component and (re)register its hit rects.
|
||||
fui::GfxRendererTarget target(renderer); // adapts GfxRenderer to FreeInkUI's DrawTarget
|
||||
target.setFont(fui::GfxRendererTarget::FONT_BODY, UI_12_FONT_ID);
|
||||
const fui::DeviceContext device = target.deviceContext(); // orientation, safe area, touch sizing
|
||||
|
||||
fui::Frame<48> frame(target, device, fui::InputSnapshot{}, interactions); // 48 = max hit rects
|
||||
fui::KeyboardProps props;
|
||||
props.layout = ¤tLayout();
|
||||
props.keyAction = ACTION_KEY; // one action id; the key is the event value
|
||||
props.inputMask = fui::InputTouch | fui::InputLongPress;
|
||||
fui::keyboard(frame, kbRect, props); // draws AND registers hit rects
|
||||
```
|
||||
|
||||
```cpp
|
||||
// Input step (each loop): feed touch state, act on routed events.
|
||||
int tx, ty, tapX, tapY, hx, hy;
|
||||
const bool pressedDown = mappedInput.wasScreenTouchDown(tx, ty);
|
||||
const bool tapped = mappedInput.wasScreenTapped(tapX, tapY);
|
||||
const bool inContact = mappedInput.isScreenTouchHeld(hx, hy);
|
||||
|
||||
const auto result = touchRouter.update(interactions, pressedDown, tx, ty,
|
||||
tapped, tapX, tapY, inContact, millis());
|
||||
if (result.event) {
|
||||
activateValue(result.event.value, result.event.longPress);
|
||||
requestUpdate();
|
||||
} else if (result.activeChanged) {
|
||||
requestUpdate(); // repaint touch-down highlight
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `Frame<N>` is templated on the max number of hit rects; the buffer is stack/static, no heap.
|
||||
- `fui::TouchHoldRouter` (see `components/keyboard/keyboard.h`) synthesizes long-press while the finger is still down and swallows the eventual release. Use it whenever a component distinguishes tap from long-press.
|
||||
- For simpler screens without long-press, `FreeInkApp` / `Screen<N>` (`FreeInkApp.h`) is the ergonomic layer: it owns the interaction buffer, offers a top-to-bottom builder (`takeTop`, footer helpers, etc.), and its `render(input)` call routes the snapshot and dispatches registered action callbacks in one step. `snapshotFrom(input, device)` in `FreeInkUIInputManager.h` builds the `InputSnapshot` with orientation-aware touch mapping.
|
||||
- Everything stays allocation-free and works on the button-only devices unchanged: physical buttons route through the same interaction table via each hit rect's `inputMask`.
|
||||
|
||||
---
|
||||
|
||||
## Global gestures: do not reimplement these
|
||||
## Path 2: Adding touch to existing hand-rolled screens
|
||||
|
||||
For activities that draw their own rows, menus, and buttons, `MappedInputManager` ([src/MappedInputManager.h](../../src/MappedInputManager.h)) exposes bridge helpers. They all return **logical screen coordinates** (orientation already applied via `GfxRenderer::tapToLogical`); never touch raw normalized panel coordinates or the SDK `InputManager` directly.
|
||||
|
||||
| Helper | Use for |
|
||||
|---|---|
|
||||
| `wasScreenTapped(x, y)` | A completed tap (press + release), with logical coords |
|
||||
| `wasScreenTouchDown(x, y)` | Touch-down (held > 90 ms, not yet released): draw selection highlight |
|
||||
| `isScreenTouchHeld(x, y)` | Live contact position while the finger is down (drag tracking) |
|
||||
| `wasTapInRect(x, y, w, h)` | One-off hit test on a rectangle (a single button, a banner) |
|
||||
| `wasListItemTapped(index, count, selected, listTop, listHeight, hasSubtitle)` | Taps on a standard UITheme list; does the row/paging math for you |
|
||||
| `wasListItemTouchedDown(...)` | Same geometry, touch-down phase (highlight before activate) |
|
||||
| `rowTouch(row, top, rowStep, rowCount, xStart, xEnd, rowHeight)` | Any custom band of equal-height rows; returns `RowTouch::None/Down/Tap` |
|
||||
| `colTouch(col, left, colStep, colCount, yStart, yEnd, colWidth)` | Horizontal button bands (dialogs, prompts) |
|
||||
| `wasSwipe()` | Raw swipe direction if you need one beyond the global gestures |
|
||||
| `hasTouch()` | True when the device has a touch panel (rarely needed; helpers simply never fire without one) |
|
||||
|
||||
### Pattern A: standard themed list
|
||||
|
||||
One call per loop; UITheme owns the row geometry ([EpubReaderBookmarksActivity.cpp:123](../../src/activities/reader/EpubReaderBookmarksActivity.cpp)):
|
||||
|
||||
```cpp
|
||||
int tapped = -1;
|
||||
if (mappedInput.wasListItemTapped(tapped, bookmarks.size(), selectorIndex, listY, listHeight, true)) {
|
||||
selectorIndex = tapped;
|
||||
openBookmark();
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern B: custom rows with hold highlight
|
||||
|
||||
For non-theme row layouts, use `rowTouch` and distinguish `Down` (highlight) from `Tap` (activate), as in [EpubReaderFootnotesActivity.cpp](../../src/activities/reader/EpubReaderFootnotesActivity.cpp):
|
||||
|
||||
```cpp
|
||||
int row = -1;
|
||||
const auto touch = mappedInput.rowTouch(row, listTop, lineHeight, visibleCount,
|
||||
contentX, contentX + contentWidth);
|
||||
if (touch != MappedInputManager::RowTouch::None) {
|
||||
const int touched = scrollOffset + row;
|
||||
if (touch == MappedInputManager::RowTouch::Down) {
|
||||
if (selectedIndex != touched) { selectedIndex = touched; requestUpdate(); }
|
||||
} else { // RowTouch::Tap
|
||||
selectedIndex = touched;
|
||||
activateSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
The `Down` state exists because e-ink repaints are slow: highlight on touch-down gives immediate feedback, activation happens on release.
|
||||
|
||||
### Global gestures: do not reimplement these
|
||||
|
||||
Three gestures are handled once, for every screen. Activities must not add their own edge-swipe handling:
|
||||
|
||||
@@ -112,25 +142,14 @@ Three gestures are handled once, for every screen. Activities must not add their
|
||||
| Home | Up-swipe starting in the bottom 14% | `ActivityManager::loop()`; pops to Home (activities can override via `handleHomeGesture()`) |
|
||||
| Menu | Down-swipe starting in the top 14% | Activities that have a menu check `wasMenuGesture()` themselves (the reader does this) |
|
||||
|
||||
Because the back gesture arrives as `Button::Back`, most button-era activities gain back-swipe support with zero changes.
|
||||
Because the back gesture arrives as `Button::Back`, most button-era activities gain back-swipe support with zero changes. That is the bar to aim for: bridge helpers should make touch an additive layer over the button flow, not a second input state machine.
|
||||
|
||||
---
|
||||
### Bridge rules
|
||||
|
||||
## Legacy bridge helpers (do not use in new code)
|
||||
|
||||
`MappedInputManager` still exposes raw touch accessors. The FUI stack consumes them internally (via `touchSnapshotFrom` in [`UiAppHelpers.h`](../../src/components/UiAppHelpers.h)); activities should not.
|
||||
|
||||
| Helper | Status |
|
||||
|---|---|
|
||||
| `wasScreenTapped` / `wasScreenTouchDown` / `isScreenTouchHeld` | Consumed by the FUI snapshot builder. Direct use only in the two legacy surfaces |
|
||||
| `wasTapInRect(x, y, w, h)` | Legacy one-off hit test |
|
||||
| `rowTouch` / `colTouch` | Legacy row/column band math. Sole remaining user: `HomeActivity` (theme-driven layout) |
|
||||
| `wasSwipe()` | Raw swipe direction, for behavior beyond the global gestures (reader page turns) |
|
||||
| `hasTouch()` | Still fine anywhere: gate touch-only chrome (on-screen Cancel/OK pairs) on it |
|
||||
|
||||
The `wasListItemTapped` / `wasListItemTouchedDown` helpers from the button-era bridge have been removed; every themed list is a `UiListActivity` now. If you are porting a branch that still calls them, convert the screen to `UiListActivity` rather than resurrecting the helpers.
|
||||
|
||||
As with all input: never call the SDK `InputManager` or read GPIO directly. The HAL rule from the main guide applies to touch too.
|
||||
- Handle touch in `loop()` next to the existing button handling, one helper call per interaction zone, and `return` after consuming an event (mirrors the button pattern).
|
||||
- Never call the SDK `InputManager` or read GPIO directly; the HAL rule from the main guide applies to touch too.
|
||||
- Coordinates from the helpers are logical and orientation-correct on all four rotations; test at least Portrait and one Landscape mode before PR.
|
||||
- Nothing to clean up in `onExit()`; the helpers are stateless from the activity's point of view.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
# SD-card plugins
|
||||
|
||||
CrossPoint keeps service integrations off the firmware. A plugin is a folder on
|
||||
the SD card; the firmware provides three generic, scheme-neutral surfaces that
|
||||
plugins compose. Adding or updating a plugin never requires a firmware build,
|
||||
and the firmware carries no vendor names, URLs, or file-format knowledge.
|
||||
|
||||
```
|
||||
<root>/<name>/
|
||||
manifest.json web UI card metadata (optional): title, description, mount
|
||||
plugin.js browser-side plugin (optional)
|
||||
device.json on-device catalog screen (optional): title, description, ...
|
||||
README.md usage instructions, shown on-device (optional)
|
||||
...assets
|
||||
```
|
||||
|
||||
**Discovery and the on-device list.** Every plugin folder (anything holding a
|
||||
`manifest.json`, `plugin.js`, or `device.json`) appears under **Settings →
|
||||
System → Plugins** on the reader, showing its `title` and one-line
|
||||
`description`. Selecting a plugin opens an info screen with the description and
|
||||
the plugin's `README.md` as scrollable usage instructions — so even a
|
||||
browser-only plugin (no `device.json`) is listed and can explain how to use it
|
||||
from the web UI. A plugin that ships a `device.json` also gets an **Open**
|
||||
action there to launch its on-device catalog. `title`/`description` are read
|
||||
from `manifest.json`, with `device.json` overriding when present.
|
||||
|
||||
`<root>` is any of `/.crosspoint/plugins`, `/plugins`, or `/.plugins` — the
|
||||
first two-dot-free options exist so plugins are easy to copy onto the card
|
||||
from a computer. All three roots are scanned; on a name collision the earlier
|
||||
root in that order wins.
|
||||
|
||||
A plugin can ship any combination: `plugin.js` alone (web-only),
|
||||
`device.json` alone (on-device only), or both (e.g. sign in from either side,
|
||||
browse on the reader).
|
||||
|
||||
The plugin sources and examples live in the separate `sd-plugins` repository,
|
||||
which also documents the browser-side `plugin.js` API. This document covers the
|
||||
firmware surfaces: the job queue and the `device.json` on-device screens.
|
||||
|
||||
## Surface 1: browser plugins (`plugin.js`)
|
||||
|
||||
JS loaded into the File Manager or Settings web page, backed by generic device
|
||||
endpoints (`/api/relay`, `/api/crypto`, `/api/fetch`, `/api/plugin-fs`). See
|
||||
`docs/webserver-endpoints.md` for the endpoints and the sd-plugins repository
|
||||
for the JS contract.
|
||||
|
||||
## Surface 2: the plugin job queue (external automation)
|
||||
|
||||
External systems (a companion app, a script) can trigger plugin actions
|
||||
without a human clicking the web UI. The firmware stores small opaque
|
||||
`{plugin, action, args}` JSON blobs in a fixed 6-slot pool; it never interprets
|
||||
them. Any open page hosting the plugin — including the headless runner at
|
||||
`GET /plugins-run` — claims jobs, executes the plugin's registered handler in
|
||||
the browser context, and posts the result back.
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `POST /api/plugin-jobs` | enqueue `{plugin, action, args?}` → `{id}` (503 when the pool is full) |
|
||||
| `GET /api/plugin-jobs/claim?plugin=` | executor claims the next pending job |
|
||||
| `POST /api/plugin-jobs/complete` | executor posts `{id, ok, result?}` |
|
||||
| `GET /api/plugin-jobs/status?id=` | caller polls: `{id, state, result}`; states: `pending`, `running`, `done`, `error`, `unknown` (recycled) |
|
||||
| `GET /plugins-run` | headless page that loads every plugin (UI hidden) and executes jobs while open |
|
||||
|
||||
Flow for an external system:
|
||||
|
||||
```sh
|
||||
# 1. upload whatever the action needs (e.g. a file, via /upload)
|
||||
# 2. enqueue
|
||||
curl -X POST http://crosspoint.local/api/plugin-jobs \
|
||||
-d '{"plugin":"<name>","action":"<action>","args":{"path":"/folder/file"}}'
|
||||
# -> {"id":7}
|
||||
# 3. open http://crosspoint.local/plugins-run in a browser tab or hidden
|
||||
# webview (jobs only execute while a hosting page is open)
|
||||
# 4. poll
|
||||
curl "http://crosspoint.local/api/plugin-jobs/status?id=7"
|
||||
# -> {"id":7,"state":"done","result":{...}}
|
||||
```
|
||||
|
||||
Limits: plugin/action names < 24 chars, args and result < 192 bytes of JSON
|
||||
each. A job claimed by a runner that dies is reclaimable after 10 minutes.
|
||||
Results persist only until their slot is recycled — poll promptly.
|
||||
|
||||
Plugins register handlers in `plugin.js`:
|
||||
|
||||
```js
|
||||
api.registerAction('myaction', async (args) => {
|
||||
// runs in the hosting page; throw -> state "error" with {error: message}
|
||||
return { anything: 'small' }; // -> state "done" with this result
|
||||
});
|
||||
```
|
||||
|
||||
## Surface 3: on-device catalog screens (`device.json`)
|
||||
|
||||
A declarative manifest the firmware's generic `PluginCatalogActivity` renders
|
||||
under **Settings → System → Plugins**. It expresses "authenticated JSON
|
||||
catalog: sign in, browse, download, sidecar" — enough for most book services —
|
||||
without any code running on the device. Anything beyond this vocabulary
|
||||
belongs in `plugin.js`.
|
||||
|
||||
The firmware pieces (all service-agnostic, in `src/activities/plugins/`):
|
||||
`PluginListActivity` (the Plugins menu), `PluginInfoActivity` (per-plugin
|
||||
description + README + Open action), `PluginCatalogActivity` (browse / download
|
||||
/ sign-in), and `discoverPlugins()` (folder scan when the menu opens; nothing
|
||||
stays resident).
|
||||
|
||||
### Schema
|
||||
|
||||
Every string field is a template. Available substitutions:
|
||||
|
||||
| Variable | Meaning | Available in |
|
||||
|---|---|---|
|
||||
| `{token}` | contents of the token file at `token.path` | everywhere |
|
||||
| `{cfg.KEY}` | value of `KEY` in the flat JSON config file at `config.file` | everywhere |
|
||||
| `{page}` | current 1-based page | browse |
|
||||
| `{limit}` | `page_size + 1` (the extra row detects "more pages") | browse |
|
||||
| `{id}` `{title}` `{author}` `{url}` | fields of the selected item | download, sidecar |
|
||||
| `{md5}` | MD5 hex of the destination file path | sidecar |
|
||||
| `{device_code}` | from the auth request response | auth poll |
|
||||
|
||||
Two browse formats:
|
||||
- **`"json"`** (default) — a paged JSON list, using `items`/`fields` below.
|
||||
- **`"xml"`** — walks a repeating XML element with optional folder navigation
|
||||
(Confirm opens a folder, Back goes up). No paging; the JSON `items`/
|
||||
`page_size` are ignored. The firmware knows no specific protocol: a WebDAV
|
||||
`PROPFIND` multistatus, an OPDS/Atom feed, or any XML list is all manifest
|
||||
config. XML field selectors are `"elem"` (child element text), `"elem@attr"`
|
||||
(child element attribute), or `"@attr"` (attribute on the item element).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"title": "Service Name", // menu label; defaults to folder name
|
||||
|
||||
"token": { // omit for token-less catalogs
|
||||
"file": "/.crosspoint/<name>.json", // written by auth (either side)
|
||||
"path": "token" // dotted JSON path inside the file
|
||||
},
|
||||
|
||||
"config": { // optional: flat JSON of {cfg.KEY} values,
|
||||
"file": "/.crosspoint/<name>-cfg.json" // e.g. a user-entered server URL + credentials
|
||||
},
|
||||
|
||||
"browse": { // required
|
||||
"format": "json", // or "xml"
|
||||
"url": "https://.../search",
|
||||
"method": "POST", // default GET
|
||||
"headers": { "Authorization": "Bearer {token}" },
|
||||
"body": "{\"page\":{page},\"per_page\":{limit}}",
|
||||
|
||||
// --- json format ---
|
||||
"items": "", // dotted path to the item array; "" = response root
|
||||
"fields": { // json: dotted paths (numeric = array index)
|
||||
"title": "title", // required (items without one are dropped)
|
||||
"author": "authors.0.name",
|
||||
"id": "id",
|
||||
"url": "download_url" // when the item carries a direct file URL
|
||||
},
|
||||
"page_size": 8, // 1..16; response should honor {limit}
|
||||
|
||||
"lists": [ // optional named sub-catalogs (json only):
|
||||
{ "title": "All Books" }, // a picker screen precedes browsing;
|
||||
{ "title": "Favorites", // each entry may override url and/or
|
||||
"body": "{\"page\":{page},\"per_page\":{limit},\"list\":\"favorites\"}" }
|
||||
], // omitted keys fall back to browse's
|
||||
|
||||
// --- xml format (ignore the json fields above; fields become selectors) ---
|
||||
"item": "response", // local-name of the repeating element
|
||||
"container_element": "collection", // presence marks a navigable folder (omit = flat)
|
||||
"skip_self": true, // drop the entry equal to the request URL
|
||||
"resolve_urls": true, // resolve url field against the request origin
|
||||
"extensions": [".epub", ".pdf"] // allowed file extensions (omit = all)
|
||||
},
|
||||
|
||||
"download": {
|
||||
"url": "https://.../books/{id}/download",
|
||||
"method": "POST",
|
||||
"headers": { "Authorization": "Bearer {token}" }, // sent on the file GET too
|
||||
"body": "{}",
|
||||
"url_path": "url", // response field with the file URL;
|
||||
// omit to treat "url" itself as the file URL
|
||||
"username": "{cfg.user}", // optional HTTP Basic creds for the file GET
|
||||
"password": "{cfg.pass}", // omit for token/header auth
|
||||
"dest_dir": "/ServiceName", // created if missing; falls back to SD root
|
||||
"filename": "{title}.epub", // {title} is filesystem-sanitized here
|
||||
"sidecar": { // optional per-book metadata file
|
||||
"path": "/.crosspoint/<name>_{md5}.json",
|
||||
"body": "{\"book_id\":{id}}"
|
||||
}
|
||||
},
|
||||
|
||||
"auth": { // optional on-device sign-in
|
||||
"type": "device_code", // or "password"
|
||||
|
||||
// device_code (interactive: shows a code + QR, polls until authorized):
|
||||
"request": { "url": "...", "method": "POST", "headers": { ... }, "body": "..." },
|
||||
"poll": { "url": "...", "method": "POST", "headers": { ... },
|
||||
"body": "{...\"device_code\":\"{device_code}\"}" },
|
||||
// device_code response field paths, with their defaults:
|
||||
"code_path": "user_code", "verify_url_path": "verification_uri",
|
||||
"device_code_path": "device_code", "interval_path": "interval",
|
||||
"expires_path": "expires_in", "token_path": "access_token",
|
||||
"error_path": "error"
|
||||
|
||||
// password (silent: mints a token from stored config credentials — for
|
||||
// OAuth2 password grants and login-returns-token APIs). Only "request" and
|
||||
// "token_path" apply; no user interaction:
|
||||
// "type": "password",
|
||||
// "request": { "url": "{cfg.url}/oauth/v2/token", "method": "POST",
|
||||
// "headers": {...}, "body": "...{cfg.username}...{cfg.password}..." },
|
||||
// "token_path": "access_token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- **Sign-in (`device_code`)**: the not-signed-in screen offers Sign in on the
|
||||
device: it shows the verification URL (text + QR) and user code, then polls
|
||||
until authorized, honoring `authorization_pending`, `slow_down`,
|
||||
`expired_token`, and `access_denied`. The token is written to `token.file`
|
||||
in the shape `token.path` expects, so the web plugin and the device share
|
||||
one sign-in.
|
||||
- **Sign-in (`password`)**: no on-device UI — the device silently POSTs the
|
||||
stored config credentials to the token endpoint before browsing, and re-mints
|
||||
a fresh token automatically after any 401 (these tokens are short-lived).
|
||||
Credentials come from a browser `plugin.js` writing the `config.file`. Set it
|
||||
up once on the web page; the reader is then standalone.
|
||||
- Without an `auth` block the not-signed-in screen directs the user to the web
|
||||
plugin.
|
||||
- **Stale tokens**: a 401/403 from browse returns to the sign-in screen rather
|
||||
than an error.
|
||||
- **Pagination** (json): the browse request should return up to `{limit}`
|
||||
items; the firmware displays `page_size` and uses the extra row to know
|
||||
another page exists. Navigation matches the OPDS browser: a "Previous
|
||||
page" row precedes the items past page 1 and a "Next page" row follows
|
||||
them while more pages exist — tappable and button-reachable like any row.
|
||||
- **Lists** (json): with `browse.lists`, opening the catalog shows a picker
|
||||
of the named entries first; each entry browses with its own url/body
|
||||
overrides (server-side categories, shelves, sort orders). Back from the
|
||||
book list returns to the picker.
|
||||
- **XML navigation**: Confirm on a folder (an item carrying `container_element`)
|
||||
descends into it, Back climbs out (Back at the root leaves the screen).
|
||||
With `resolve_urls`, server-relative URLs resolve against the browse URL's
|
||||
origin. A `config.file` lets values like a server URL and credentials be
|
||||
user-entered (via a browser `plugin.js`) rather than baked into the manifest.
|
||||
- **After download**: the book's layout cache is invalidated and the optional
|
||||
sidecar is written (e.g. a service book id keyed by path MD5, for a future
|
||||
progress-sync stage).
|
||||
|
||||
### Heap budget (ESP32-C3, ~380KB total)
|
||||
|
||||
- Manifest ≤ 8KB, token file ≤ 2KB, small API responses (auth, download-url
|
||||
hop) ≤ 48KB (hard caps).
|
||||
- Browse responses (json and xml) stream to an SD temp file and are parsed
|
||||
from it, so the raw body never occupies DRAM — services that inline heavy
|
||||
per-item metadata (a page of BookFusion JSON runs 60+ KB) work unmodified.
|
||||
A 1MB cap bounds a misbehaving server.
|
||||
- Catalog responses are parsed with a dynamically built ArduinoJson filter
|
||||
containing only the declared field paths, so a page parses into a few KB
|
||||
no matter the body size.
|
||||
- One TLS session is kept alive across browse requests: repeated handshakes
|
||||
permanently fragment the heap.
|
||||
- The book download streams to SD via `HttpDownloader`; file size is
|
||||
unconstrained.
|
||||
- An XML list is parsed from the temp file in 2KB chunks; the engine stops
|
||||
after 200 entries per folder/feed. Split very large libraries into
|
||||
subfolders.
|
||||
|
||||
### Testing a new manifest
|
||||
|
||||
1. Copy the plugin folder to `/.crosspoint/plugins/<name>/` on the SD card.
|
||||
2. Settings → System → Plugins → your title. With no token and no `auth`
|
||||
block you should see the not-signed-in screen; with `auth`, the code/QR
|
||||
screen.
|
||||
3. Watch serial (`[PCAT]` tag) for request/parse failures — the log includes
|
||||
HTTP status and truncation flags.
|
||||
4. Token-less services (public JSON APIs) work by omitting the `token` block
|
||||
entirely; that is the quickest way to validate `items`/`fields` paths.
|
||||
|
||||
## Ideas to build
|
||||
|
||||
The two planes decide *how* you build something, so pick by what the idea needs:
|
||||
|
||||
- **On-device (`device.json`)** — standalone on the reader, but only the
|
||||
declarative vocabulary above: an authenticated JSON or XML/OPDS-style catalog,
|
||||
browse, download to SD, optional device-code or password sign-in and per-book
|
||||
sidecar. No arbitrary logic.
|
||||
- **Browser (`plugin.js`)** — anything at all (any protocol, auth, format), but
|
||||
it runs in a connected browser/app. Use it when the idea needs request
|
||||
signing, HTML→EPUB conversion, multi-file writes, or a real UI. The job queue
|
||||
(`/api/plugin-jobs` + `/plugins-run`) lets an external app trigger it.
|
||||
|
||||
Rule of thumb: if the service is "log in, list an authenticated JSON/XML
|
||||
catalog, download files," it's on-device. If it needs computed request bodies
|
||||
(OAuth 1.0a signing, canonicalized signatures) or format conversion, it's
|
||||
browser-side.
|
||||
|
||||
### On-device (`device.json`) ideas
|
||||
|
||||
- **Self-hosted library presets** — Kavita, Komga, Audiobookshelf, Calibre-Web,
|
||||
COPS. Most expose OPDS (native OPDS browsing already covers those — a plugin
|
||||
adds value mainly for their JSON APIs or token auth). Build: `browse.format`
|
||||
`json` against the REST API, or `xml` against the OPDS feed; `download.url`
|
||||
from the entry; bearer/Basic/`{cfg.*}` auth. See `bookfusion` (JSON + token)
|
||||
and `webdav` (XML + config creds).
|
||||
- **Dictionary store** — browse a JSON index of dictionary files, download to
|
||||
the SD dictionary dir. No native equivalent. Build: token-less `json` catalog,
|
||||
`download.dest_dir` = the dictionary folder, no sidecar. ~15 lines.
|
||||
- **Theme store / font store** — same shape as the dictionary store, pointed at
|
||||
the theme or font directory. Asset catalogs OPDS can't express.
|
||||
- **More read-later that exports EPUB** — anything with a token API and an
|
||||
EPUB export URL fits like `wallabag`: `auth.type` `password` (or a stored
|
||||
bearer), `browse` the entries JSON, `download` the export endpoint with a
|
||||
Bearer header.
|
||||
- **A curated free-books catalog** — Standard Ebooks / Gutenberg via their OPDS
|
||||
feed (`xml` format, token-less). Overlaps native OPDS, but ships as a
|
||||
zero-config preset.
|
||||
|
||||
### Browser (`plugin.js`) ideas
|
||||
|
||||
- **Plugin store** — install other plugins from a hosted catalog. Build: fetch
|
||||
a catalog with `api.relay`, `/mkdir` the target folder, `api.writeFile` each
|
||||
file. See `plugin-store/`.
|
||||
- **AO3 / fanfiction downloader** — the site serves per-work EPUB downloads.
|
||||
Build: take a work URL, `api.fetchToSd` (or `api.relay` + `api.writeFile`) the
|
||||
EPUB to SD. Browsing/search is HTML, so parse it in the browser.
|
||||
- **RSS → EPUB digest** — build a "morning edition." Fetch feeds with
|
||||
`api.relay`, assemble an EPUB in the browser with the JSZip the File Manager
|
||||
page already loads, then `api.writeFile` it to SD.
|
||||
- **Read-any-article** — paste a URL, run it through a readability service via
|
||||
`api.relay`, wrap the result as an EPUB, write to SD.
|
||||
- **Metadata / cover editor** — a File Manager plugin: open an EPUB with JSZip,
|
||||
edit the OPF (title/author/cover), rewrite it in place. See
|
||||
`organize-by-author/` for the File-Manager-endpoints pattern.
|
||||
- **Library tidy tools** — dedupe, bulk rename, move-by-metadata. Same
|
||||
same-origin endpoints (`/api/files`, `/mkdir`, `/move`, `/delete`), no device
|
||||
API needed.
|
||||
- **Signed-API services** (Instapaper OAuth 1.0a, etc.) — must be browser-side:
|
||||
do the per-request signing in JS with `crypto.subtle`, relay the calls, and
|
||||
convert the article HTML to EPUB before writing to SD.
|
||||
|
||||
### Building blocks reference
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Outbound HTTP(S), any method (CORS-free) | `api.relay(method, url, headers, body)` |
|
||||
| Download a URL straight to SD | `api.fetchToSd(url, dest, headers)` |
|
||||
| Write a small file to SD | `api.writeFile(path, base64)` |
|
||||
| Crypto (hash, HMAC via SHA, AES, RSA) | `api.crypto(op, fields)` or browser `crypto.subtle` |
|
||||
| Create / delete / move SD files | same-origin `/mkdir`, `/delete`, `/move` |
|
||||
| On-device catalog/browse/download | `device.json` (this document) |
|
||||
| External app triggers an action | `api.registerAction(name, fn)` + `/api/plugin-jobs` |
|
||||
@@ -504,43 +504,3 @@ The final field is the WebSocket upload port.
|
||||
|
||||
Calibre Wireless starts the same web server in STA mode and displays setup
|
||||
instructions plus WebSocket upload progress on the device screen.
|
||||
|
||||
## Plugin Job Queue
|
||||
|
||||
External systems trigger SD-plugin actions without the web UI. The firmware
|
||||
stores opaque `{plugin, action, args}` blobs (fixed 6-slot pool; args/result
|
||||
< 192 bytes JSON); an open page hosting the plugin executes them. See
|
||||
`docs/sd-plugins.md` for the full contract.
|
||||
|
||||
### `POST /api/plugin-jobs`
|
||||
|
||||
```bash
|
||||
curl -X POST http://crosspoint.local/api/plugin-jobs \
|
||||
-d '{"plugin":"<name>","action":"<action>","args":{"path":"/Books/somefile"}}'
|
||||
# -> {"id":3} (503 {"error":"job queue full"} when all slots busy)
|
||||
```
|
||||
|
||||
### `GET /api/plugin-jobs/claim?plugin=<name>`
|
||||
|
||||
Executor-side (used by the plugin host page): returns the next pending job
|
||||
`{"id":3,"action":"<action>","args":{...}}` and marks it running, or `{"id":0}`.
|
||||
|
||||
### `POST /api/plugin-jobs/complete`
|
||||
|
||||
Executor-side: `{"id":3,"ok":true,"result":{...}}` -> `{"ok":true}`.
|
||||
|
||||
### `GET /api/plugin-jobs/status?id=<n>`
|
||||
|
||||
```bash
|
||||
curl "http://crosspoint.local/api/plugin-jobs/status?id=3"
|
||||
# -> {"id":3,"state":"done","result":{"title":"...","dest":"/Books/Book.epub"}}
|
||||
```
|
||||
|
||||
States: `pending`, `running`, `done`, `error`, `unknown` (slot recycled —
|
||||
poll promptly after completion).
|
||||
|
||||
### `GET /plugins-run`
|
||||
|
||||
Headless executor page: loads every plugin with its UI hidden so registered
|
||||
actions run. Keep it open (browser tab or embedded webview) while jobs are
|
||||
queued; jobs enqueued with no page open stay pending until one opens.
|
||||
+1
-1
Submodule freeink-sdk updated: 58d34b3788...a485dc46ef.
@@ -1,146 +0,0 @@
|
||||
// SD/HAL binding for the content-protection read path.
|
||||
//
|
||||
// The ContentProtection SDK lib is storage-agnostic (it works against a
|
||||
// ByteSource). This file is the firmware-side glue that backs that seam with
|
||||
// the device's SD storage: a HalStorage-backed ByteSource, the credential
|
||||
// lookup, and the openProtectedBook() entry point the reader calls. It lives in
|
||||
// the firmware — not the SDK lib — so the portable lib carries no HAL dependency.
|
||||
|
||||
#include <ByteSource.h>
|
||||
#include <ContentProtection.h>
|
||||
#include <Credential.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Memory.h>
|
||||
#include <ProtectedBook.h>
|
||||
#include <WolfsslCrypto.h>
|
||||
#include <Zip.h>
|
||||
#include <esp_heap_caps.h>
|
||||
#include <time.h>
|
||||
|
||||
namespace freeink {
|
||||
namespace content {
|
||||
|
||||
namespace {
|
||||
|
||||
// The access credential is provisioned off-device and dropped here.
|
||||
// Generic path — the reader carries no scheme name.
|
||||
constexpr const char* kCredentialPath = "/.crosspoint/content.key";
|
||||
|
||||
// One shared crypto backend for the whole read path.
|
||||
WolfsslCrypto& crypto() {
|
||||
static WolfsslCrypto instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
// ByteSource over an SD file (read-only). One open handle per instance.
|
||||
class SdByteSource : public ByteSource {
|
||||
public:
|
||||
explicit SdByteSource(std::string path) : path_(std::move(path)) {}
|
||||
bool open() {
|
||||
file_ = Storage.open(path_.c_str(), O_RDONLY);
|
||||
return file_ && file_.isOpen();
|
||||
}
|
||||
int32_t readAt(uint64_t offset, void* dst, uint32_t len) override {
|
||||
if (!file_ || !file_.seek64(offset)) return -1;
|
||||
return file_.read(dst, len);
|
||||
}
|
||||
uint64_t size() const override { return file_ ? file_.fileSize64() : 0; }
|
||||
|
||||
private:
|
||||
std::string path_;
|
||||
mutable HalFile file_;
|
||||
};
|
||||
|
||||
// Adapts an opened ProtectedBook to the reader-facing access interface.
|
||||
class ProtectedBookDecryptor : public ContentDecryptor {
|
||||
public:
|
||||
ProtectedBookDecryptor(std::string epubPath, std::unique_ptr<ProtectedBook> book)
|
||||
: epubPath_(std::move(epubPath)), book_(std::move(book)) {}
|
||||
|
||||
bool isEncrypted(const std::string& itemPath) const override { return book_->isEncrypted(itemPath); }
|
||||
|
||||
size_t decryptedSize(const std::string& itemPath) const override { return book_->decryptedSize(itemPath); }
|
||||
|
||||
bool decryptToSink(const std::string& itemPath, ContentChunkSink sink, void* context) override {
|
||||
SdByteSource source(epubPath_);
|
||||
if (!source.open()) return false;
|
||||
return book_->decryptEntryToSink(source, crypto(), itemPath, sink, context);
|
||||
}
|
||||
|
||||
private:
|
||||
std::string epubPath_;
|
||||
std::unique_ptr<ProtectedBook> book_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<ContentDecryptor> openProtectedBook(const std::string& epubPath, std::string& err) {
|
||||
err.clear();
|
||||
if (!Storage.exists(epubPath.c_str())) return nullptr;
|
||||
|
||||
SdByteSource source(epubPath);
|
||||
if (!source.open()) return nullptr;
|
||||
|
||||
// Classify before initializing crypto or loading credentials, then transfer
|
||||
// this same ZIP index into ProtectedBook. Protected EPUBs still scan only
|
||||
// once; plain EPUBs return immediately through the normal reader path.
|
||||
ZipScan scan;
|
||||
if (!scan.open(source) || !scan.find("META-INF/encryption.xml")) return nullptr;
|
||||
|
||||
// A book carrying encryption.xml may only obfuscate its embedded fonts
|
||||
// (not content-protected). The SDK demands the credential only after parsing
|
||||
// the manifest and finding genuinely encrypted entries.
|
||||
SdByteSource credSource(kCredentialPath);
|
||||
Credential credential;
|
||||
const bool haveCredential = credSource.open() && parseCredential(credSource, &credential);
|
||||
|
||||
auto book = makeUniqueNoThrow<ProtectedBook>();
|
||||
if (!book) {
|
||||
err = "out of memory";
|
||||
return nullptr;
|
||||
}
|
||||
// Prefer an out-of-band rights document delivered as a sidecar next to the
|
||||
// EPUB ("<book>.epub.rights"), so the EPUB on disk stays byte-identical to
|
||||
// what the server sent. Falls back to a rights.xml injected into the zip.
|
||||
std::string rightsOverride;
|
||||
{
|
||||
// A real rights document is a few KB; 64KB is a generous ceiling. The
|
||||
// largest-block check keeps the resize below from aborting on OOM (string
|
||||
// growth is a bare allocation under -fno-exceptions).
|
||||
constexpr uint64_t kMaxRightsSize = 64 * 1024;
|
||||
SdByteSource rightsSource(epubPath + ".rights");
|
||||
if (rightsSource.open()) {
|
||||
const uint64_t rsize = rightsSource.size();
|
||||
if (rsize > 0 && rsize <= kMaxRightsSize &&
|
||||
heap_caps_get_largest_free_block(MALLOC_CAP_8BIT) > static_cast<size_t>(rsize) + 8 * 1024) {
|
||||
rightsOverride.resize(static_cast<size_t>(rsize));
|
||||
const int32_t rn = rightsSource.readAt(0, rightsOverride.data(), static_cast<uint32_t>(rsize));
|
||||
if (rn <= 0)
|
||||
rightsOverride.clear();
|
||||
else
|
||||
rightsOverride.resize(static_cast<size_t>(rn));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!book->openFromScan(source, crypto(), credential, std::move(scan), rightsOverride)) {
|
||||
err = haveCredential ? ("cannot open protected content: " + book->lastError())
|
||||
: "no content access key on this device";
|
||||
return nullptr;
|
||||
}
|
||||
// An encryption manifest containing only font obfuscation does not require
|
||||
// this read path; let the reader open it normally.
|
||||
if (!book->isProtected()) return nullptr;
|
||||
|
||||
const int64_t now = static_cast<int64_t>(time(nullptr));
|
||||
if (book->isExpired(now)) {
|
||||
err = "access expired";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto decryptor = makeUniqueNoThrow<ProtectedBookDecryptor>(epubPath, std::move(book));
|
||||
if (!decryptor) err = "out of memory";
|
||||
return decryptor;
|
||||
}
|
||||
|
||||
} // namespace content
|
||||
} // namespace freeink
|
||||
+28
-72
@@ -8,10 +8,6 @@
|
||||
#include <Utf8.h>
|
||||
#include <ZipFile.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "Epub/parsers/ContainerParser.h"
|
||||
#include "Epub/parsers/ContentOpfParser.h"
|
||||
#include "Epub/parsers/TocNavParser.h"
|
||||
@@ -361,22 +357,6 @@ void Epub::parseCssFiles() const {
|
||||
bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
|
||||
LOG_DBG("EBP", "Loading ePub: %s", filepath.c_str());
|
||||
|
||||
// Open the optional encrypted-entry accessor. A null result without an error
|
||||
// means normal ZIP reads should be used. A hard error refuses the open with
|
||||
// a user-presentable reason.
|
||||
{
|
||||
std::string err;
|
||||
decryptor = freeink::content::openProtectedBook(filepath, err);
|
||||
if (!err.empty()) {
|
||||
LOG_ERR("EBP", "protected content unavailable: %s", err.c_str());
|
||||
protectionError = err;
|
||||
return false;
|
||||
}
|
||||
if (decryptor) {
|
||||
LOG_DBG("EBP", "protected content; on-read access path open");
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize spine/TOC cache
|
||||
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
||||
// Always create CssParser - needed for inline style parsing even without CSS files
|
||||
@@ -600,9 +580,15 @@ bool Epub::generateCoverBmp(bool cropped) const {
|
||||
if (FsHelpers::hasJpgExtension(coverImageHref)) {
|
||||
LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit");
|
||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||
if (!extractItemToFile(coverImageHref, coverJpgTempPath)) return false;
|
||||
|
||||
HalFile coverJpg;
|
||||
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
||||
// Explicitly close() file before reopening for reading
|
||||
coverJpg.close();
|
||||
|
||||
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
@@ -628,9 +614,15 @@ bool Epub::generateCoverBmp(bool cropped) const {
|
||||
if (FsHelpers::hasPngExtension(coverImageHref)) {
|
||||
LOG_DBG("EBP", "Generating BMP from PNG cover image (%s mode)", cropped ? "cropped" : "fit");
|
||||
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
||||
if (!extractItemToFile(coverImageHref, coverPngTempPath)) return false;
|
||||
|
||||
HalFile coverPng;
|
||||
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(coverImageHref, coverPng, 1024);
|
||||
// Explicitly close() file before reopening for reading
|
||||
coverPng.close();
|
||||
|
||||
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
@@ -677,9 +669,15 @@ bool Epub::generateThumbBmp(int height) const {
|
||||
} else if (FsHelpers::hasJpgExtension(coverImageHref)) {
|
||||
LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
|
||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||
if (!extractItemToFile(coverImageHref, coverJpgTempPath)) return false;
|
||||
|
||||
HalFile coverJpg;
|
||||
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
||||
// Explicitly close() file before reopening for reading
|
||||
coverJpg.close();
|
||||
|
||||
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
@@ -708,9 +706,15 @@ bool Epub::generateThumbBmp(int height) const {
|
||||
} else if (FsHelpers::hasPngExtension(coverImageHref)) {
|
||||
LOG_DBG("EBP", "Generating thumb BMP from PNG cover image");
|
||||
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
||||
if (!extractItemToFile(coverImageHref, coverPngTempPath)) return false;
|
||||
|
||||
HalFile coverPng;
|
||||
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(coverImageHref, coverPng, 1024);
|
||||
// Explicitly close() file before reopening for reading
|
||||
coverPng.close();
|
||||
|
||||
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
@@ -752,38 +756,6 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
|
||||
|
||||
const std::string path = FsHelpers::normalisePath(itemHref);
|
||||
|
||||
// Decode encrypted entries on demand in memory.
|
||||
if (decryptor && decryptor->isEncrypted(path)) {
|
||||
const size_t plainSize = decryptor->decryptedSize(path);
|
||||
if (plainSize > SIZE_MAX - (trailingNullByte ? 1 : 0)) return nullptr;
|
||||
const size_t total = plainSize + (trailingNullByte ? 1 : 0);
|
||||
uint8_t* content = static_cast<uint8_t*>(malloc(total > 0 ? total : 1));
|
||||
if (!content) {
|
||||
LOG_ERR("EBP", "insufficient memory for %s (%u bytes)", path.c_str(), static_cast<unsigned>(total));
|
||||
return nullptr;
|
||||
}
|
||||
struct BufferSink {
|
||||
uint8_t* data;
|
||||
size_t capacity;
|
||||
size_t written;
|
||||
} state{content, plainSize, 0};
|
||||
auto append = [](void* context, const uint8_t* data, size_t size) {
|
||||
auto* target = static_cast<BufferSink*>(context);
|
||||
if (size > target->capacity - target->written) return false;
|
||||
memcpy(target->data + target->written, data, size);
|
||||
target->written += size;
|
||||
return true;
|
||||
};
|
||||
if (!decryptor->decryptToSink(path, append, &state) || state.written != plainSize) {
|
||||
free(content);
|
||||
LOG_ERR("EBP", "content read failed for %s", path.c_str());
|
||||
return nullptr;
|
||||
}
|
||||
if (trailingNullByte) content[plainSize] = 0;
|
||||
if (size) *size = plainSize;
|
||||
return content;
|
||||
}
|
||||
|
||||
const auto content = ZipFile(filepath).readFileToMemory(path.c_str(), size, trailingNullByte);
|
||||
if (!content) {
|
||||
LOG_DBG("EBP", "Failed to read item %s", path.c_str());
|
||||
@@ -801,22 +773,6 @@ bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, con
|
||||
}
|
||||
|
||||
const std::string path = FsHelpers::normalisePath(itemHref);
|
||||
|
||||
if (decryptor && decryptor->isEncrypted(path)) {
|
||||
struct PrintSink {
|
||||
Print* output;
|
||||
} state{&out};
|
||||
auto append = [](void* context, const uint8_t* data, size_t size) {
|
||||
auto* target = static_cast<PrintSink*>(context);
|
||||
return target->output->write(data, size) == size;
|
||||
};
|
||||
if (!decryptor->decryptToSink(path, append, &state)) {
|
||||
LOG_ERR("EBP", "content read failed for %s", path.c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize, allowEarlyStop);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <ContentProtection.h>
|
||||
#include <Print.h>
|
||||
|
||||
#include <memory>
|
||||
@@ -30,11 +29,6 @@ class Epub {
|
||||
std::unique_ptr<CssParser> cssParser;
|
||||
// CSS files
|
||||
std::vector<std::string> cssFiles;
|
||||
// Optional encrypted-entry accessor. Entries are decoded in memory and stay
|
||||
// encrypted at rest. Null when the accessor is not needed or unavailable.
|
||||
std::unique_ptr<freeink::content::ContentDecryptor> decryptor;
|
||||
// User-presentable reason the encrypted-entry accessor could not be opened.
|
||||
std::string protectionError;
|
||||
|
||||
bool findContentOpfFile(std::string* contentOpfFile) const;
|
||||
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata, bool writeSpineEntries = true);
|
||||
@@ -55,9 +49,6 @@ class Epub {
|
||||
void setupCacheDir() const;
|
||||
const std::string& getCachePath() const;
|
||||
const std::string& getPath() const;
|
||||
// Empty unless the encrypted-entry accessor failed to open.
|
||||
const std::string& getProtectionError() const { return protectionError; }
|
||||
bool isProtected() const { return decryptor != nullptr; }
|
||||
const std::string& getTitle() const;
|
||||
const std::string& getAuthor() const;
|
||||
const std::string& getLanguage() const;
|
||||
|
||||
@@ -33,7 +33,10 @@ namespace {
|
||||
// v35: Persist a uint32_t visible-text start offset for every page.
|
||||
// v36: Ruby and CJK justification layout changes invalidate cached word positions.
|
||||
// v37: Footnote href records grew from 96 to 256 bytes.
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 37;
|
||||
// v38: Image top margin is clamped so a full-viewport-height image cannot
|
||||
// overflow the page bottom; older caches can hold placements that panels
|
||||
// with no bottom inset refuse to draw.
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 38;
|
||||
// Written into the version field while a build is in progress; patched to
|
||||
// SECTION_FILE_VERSION only when the build is finalized. An abandoned /
|
||||
// crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
#include "ImageToFramebufferDecoder.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Logging.h>
|
||||
|
||||
bool ImageToFramebufferDecoder::validateImageDimensions(int width, int height, const std::string& format) {
|
||||
if (width * height > MAX_SOURCE_PIXELS) {
|
||||
LOG_ERR("IMG", "Image too large (%dx%d = %d pixels %s), max supported: %d pixels", width, height, width * height,
|
||||
format.c_str(), MAX_SOURCE_PIXELS);
|
||||
// 64-bit multiply: a malformed header (e.g. 40000x60000) overflows a plain
|
||||
// int product before the comparison can reject it.
|
||||
const int64_t pixels = static_cast<int64_t>(width) * static_cast<int64_t>(height);
|
||||
if (pixels > MAX_SOURCE_PIXELS) {
|
||||
LOG_ERR("IMG", "Image too large (%dx%d = %lld pixels %s), max supported: %lld pixels", width, height,
|
||||
static_cast<long long>(pixels), format.c_str(), static_cast<long long>(MAX_SOURCE_PIXELS));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ImageToFramebufferDecoder::yieldDuringDecode(uint32_t& lastYieldMs) {
|
||||
const uint32_t now = millis();
|
||||
if (now - lastYieldMs >= 250) {
|
||||
lastYieldMs = now;
|
||||
vTaskDelay(1);
|
||||
}
|
||||
}
|
||||
|
||||
void ImageToFramebufferDecoder::warnUnsupportedFeature(const std::string& feature, const std::string& imagePath) {
|
||||
LOG_ERR("IMG", "Warning: Unsupported feature '%s' in image '%s'. Image may not display correctly.", feature.c_str(),
|
||||
imagePath.c_str());
|
||||
|
||||
@@ -31,9 +31,21 @@ class ImageToFramebufferDecoder {
|
||||
|
||||
virtual const char* getFormatName() const = 0;
|
||||
|
||||
// Call from per-row/per-MCU decode callbacks (free functions, hence public):
|
||||
// yields one tick at most every 250 ms so multi-second decodes keep the idle
|
||||
// task (and its watchdog) fed. `lastYieldMs` is caller-held state,
|
||||
// initialized to the decode start time.
|
||||
static void yieldDuringDecode(uint32_t& lastYieldMs);
|
||||
|
||||
protected:
|
||||
// Size validation helpers
|
||||
static constexpr int MAX_SOURCE_PIXELS = 3145728; // 2048 * 1536
|
||||
// Size validation helpers. The cap bounds decode TIME, not memory: both decoders
|
||||
// stream (JPEG in MCU bands at 1/2..1/8 coarse scale, PNG scanline-by-scanline
|
||||
// with its own width-based row-buffer guard), so RAM never scales with source
|
||||
// area. 8 MP admits real-world ebook covers (KDP recommends 1600x2560 and
|
||||
// 2000x3000) while keeping a worst-case single decode in single-digit seconds;
|
||||
// the row callbacks yield periodically so a long decode cannot starve the idle
|
||||
// task's watchdog.
|
||||
static constexpr int64_t MAX_SOURCE_PIXELS = 8388608; // 8 MP (e.g. 2048 * 4096)
|
||||
|
||||
bool validateImageDimensions(int width, int height, const std::string& format);
|
||||
void warnUnsupportedFeature(const std::string& feature, const std::string& imagePath);
|
||||
|
||||
@@ -46,6 +46,8 @@ struct JpegContext {
|
||||
|
||||
PixelCache cache;
|
||||
bool caching{false};
|
||||
|
||||
uint32_t lastYieldMs{0}; // throttle state for yieldDuringDecode()
|
||||
};
|
||||
|
||||
// File I/O callbacks use pFile->fHandle to access the HalFile*,
|
||||
@@ -122,6 +124,8 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
||||
JpegContext* ctx = reinterpret_cast<JpegContext*>(pDraw->pUser);
|
||||
if (!ctx || !ctx->config || !ctx->renderer) return 0;
|
||||
|
||||
ImageToFramebufferDecoder::yieldDuringDecode(ctx->lastYieldMs);
|
||||
|
||||
// In EIGHT_BIT_GRAYSCALE mode, pPixels contains 8-bit grayscale values
|
||||
// Buffer is densely packed: stride = pDraw->iWidth, valid columns = pDraw->iWidthUsed
|
||||
uint8_t* pixels = reinterpret_cast<uint8_t*>(pDraw->pPixels);
|
||||
@@ -494,6 +498,7 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
|
||||
}
|
||||
|
||||
unsigned long decodeStart = millis();
|
||||
ctx.lastYieldMs = decodeStart;
|
||||
rc = jpeg->decode(0, 0, jpegScaleOption);
|
||||
unsigned long decodeTime = millis() - decodeStart;
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ struct PngContext {
|
||||
bool caching{false};
|
||||
|
||||
uint8_t* grayLineBuffer{nullptr};
|
||||
|
||||
uint32_t lastYieldMs{0}; // throttle state for yieldDuringDecode()
|
||||
};
|
||||
|
||||
// File I/O callbacks use pFile->fHandle to access the HalFile*,
|
||||
@@ -206,6 +208,8 @@ int pngDrawCallback(PNGDRAW* pDraw) {
|
||||
PngContext* ctx = reinterpret_cast<PngContext*>(pDraw->pUser);
|
||||
if (!ctx || !ctx->config || !ctx->renderer || !ctx->grayLineBuffer) return 0;
|
||||
|
||||
ImageToFramebufferDecoder::yieldDuringDecode(ctx->lastYieldMs);
|
||||
|
||||
int srcY = pDraw->y;
|
||||
int srcWidth = ctx->srcWidth;
|
||||
|
||||
@@ -432,6 +436,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
|
||||
}
|
||||
|
||||
unsigned long decodeStart = millis();
|
||||
ctx.lastYieldMs = decodeStart;
|
||||
rc = png->decode(&ctx, 0);
|
||||
unsigned long decodeTime = millis() - decodeStart;
|
||||
|
||||
|
||||
@@ -793,7 +793,18 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
self->currentPageVisibleOffsetSet = false;
|
||||
}
|
||||
|
||||
// Apply top margin from container block
|
||||
// Apply top margin from container block. Clamp it so the image never
|
||||
// overflows the page bottom: a full-viewport-height image leaves no room
|
||||
// for the margin, and the break above only fires on non-empty pages, so a
|
||||
// fresh page would otherwise place the image at y=marginTop and run
|
||||
// marginTop pixels past viewportHeight. A large bottom reserve (status
|
||||
// bar / big screen margin) absorbs that overflow silently, but with a
|
||||
// thin reserve it crosses the physical screen edge and fails
|
||||
// ImageBlock::render's bounds check, dropping the image entirely.
|
||||
if (self->currentPageNextY + imageMarginTop + displayHeight > self->viewportHeight) {
|
||||
const int room = self->viewportHeight - displayHeight - self->currentPageNextY;
|
||||
imageMarginTop = static_cast<int16_t>(room > 0 ? room : 0);
|
||||
}
|
||||
self->currentPageNextY += imageMarginTop;
|
||||
|
||||
// Create ImageBlock and add to page
|
||||
|
||||
@@ -727,14 +727,11 @@ void GfxRenderer::drawRect(const int x, const int y, const int width, const int
|
||||
// Border is inside the rectangle
|
||||
void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const int lineWidth,
|
||||
const bool state) const {
|
||||
// Keep the border inside [x, x+width) like the thin overload: the previous
|
||||
// right/bottom edges at x+width / y+height sat one pixel outside the rect,
|
||||
// so stroked boxes looked shifted against fills computed from the rect.
|
||||
for (int i = 0; i < lineWidth; i++) {
|
||||
drawLine(x + i, y + i, x + width - 1 - i, y + i, state);
|
||||
drawLine(x + width - 1 - i, y + i, x + width - 1 - i, y + height - 1 - i, state);
|
||||
drawLine(x + width - 1 - i, y + height - 1 - i, x + i, y + height - 1 - i, state);
|
||||
drawLine(x + i, y + height - 1 - i, x + i, y + i, state);
|
||||
drawLine(x + i, y + i, x + width - i, y + i, state);
|
||||
drawLine(x + width - i, y + i, x + width - i, y + height - i, state);
|
||||
drawLine(x + width - i, y + height - i, x + i, y + height - i, state);
|
||||
drawLine(x + i, y + height - i, x + i, y + i, state);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -206,8 +206,8 @@ STR_UNNAMED: "Без імя"
|
||||
STR_NO_SERVER_URL: "URL сервера не наладжаны"
|
||||
STR_FETCH_FEED_FAILED: "Не ўдалося атрымаць стужку"
|
||||
STR_PARSE_FEED_FAILED: "Не ўдалося апрацаваць стужку"
|
||||
STR_NEXT_PAGE: "Наступная старонка"
|
||||
STR_PREV_PAGE: "Папярэдняя старонка"
|
||||
STR_NEXT_PAGE: "Наступная старонка »"
|
||||
STR_PREV_PAGE: "« Папярэдняя старонка"
|
||||
STR_NETWORK_PREFIX: "Сетка:"
|
||||
STR_IP_ADDRESS_PREFIX: "IP-адрас:"
|
||||
STR_ERROR_GENERAL_FAILURE: "Памылка: Агульная памылка"
|
||||
|
||||
@@ -218,8 +218,8 @@ STR_HOLD_OPEN_TO_DELETE: "Drži Otvori za brisanje"
|
||||
STR_NO_SERVER_URL: "Nije podešen URL servera"
|
||||
STR_FETCH_FEED_FAILED: "Dohvatanje feeda nije uspjelo"
|
||||
STR_PARSE_FEED_FAILED: "Obrada feeda nije uspjela"
|
||||
STR_NEXT_PAGE: "Sljedeća stranica"
|
||||
STR_PREV_PAGE: "Prethodna stranica"
|
||||
STR_NEXT_PAGE: "Sljedeća stranica »"
|
||||
STR_PREV_PAGE: "« Prethodna stranica"
|
||||
STR_NETWORK_PREFIX: "Mreža: "
|
||||
STR_IP_ADDRESS_PREFIX: "IP adresa: "
|
||||
STR_ERROR_GENERAL_FAILURE: "Greška: Opšta greška"
|
||||
|
||||
@@ -351,8 +351,8 @@ STR_AUTO_TURN_PAGES_PER_MIN: "Pas automàtic de pàgina (pàg./min)"
|
||||
STR_TILT_PAGE_TURN: "Pas de pàgina per inclinació"
|
||||
STR_FORCE_REFRESH: "Refresca la pantalla"
|
||||
STR_RESTARTING_HINT: "S'està reiniciant... Si el dispositiu no es reinicia, mantén premut el botó d'encesa durant uns segons."
|
||||
STR_NEXT_PAGE: "Pàgina següent"
|
||||
STR_PREV_PAGE: "Pàgina anterior"
|
||||
STR_NEXT_PAGE: "Pàgina següent »"
|
||||
STR_PREV_PAGE: "« Pàgina anterior"
|
||||
STR_XTC_STATUS_BAR: "Barra d'estat XTC"
|
||||
STR_BOTTOM: "Inferior"
|
||||
STR_TOP: "Superior"
|
||||
|
||||
Loaded 20 of 154 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user