diff --git a/.luals/crosspoint.lua b/.luals/crosspoint.lua new file mode 100644 index 0000000..4a0582c --- /dev/null +++ b/.luals/crosspoint.lua @@ -0,0 +1,441 @@ +---@meta + +-- CrossPoint Lua API stub for LuaLS. GENERATED by scripts/gen_lua_stubs.py +-- from the annotations in src/util/LuaManager.cpp; do not edit by hand. + +---@alias CrossPointButton +---| "back" +---| "confirm" +---| "left" +---| "right" +---| "up" +---| "down" +---| "page_back" +---| "page_forward" + +---@alias CrossPointOrientation +---| "portrait" +---| "portrait_inv" +---| "landscape_cw" +---| "landscape_ccw" + +---@class app +app = {} + +---@class fs +fs = {} + +---@class gui +---@field HINT_BACK string +---@field HINT_OK string +---@field HINT_PREV string +---@field HINT_NEXT string +gui = {} +gui.HINT_BACK = "<<" +gui.HINT_OK = "o" +gui.HINT_PREV = "<" +gui.HINT_NEXT = ">" + +---@class http +http = {} + +---@class input +input = {} + +---@class log +log = {} + +---@class sys +sys = {} + +---@class timer +timer = {} + +---@class wifi +wifi = {} + +---@type integer +COLOR_BLACK = 0 +---@type integer +COLOR_CLEAR = 0 +---@type integer +COLOR_DARK_GRAY = 0 +---@type integer +COLOR_LIGHT_GRAY = 0 +---@type integer +COLOR_WHITE = 0 +---@type integer +FONT_BOOKERLY_12 = 0 +---@type integer +FONT_BOOKERLY_14 = 0 +---@type integer +FONT_BOOKERLY_16 = 0 +---@type integer +FONT_NOTOSANS_12 = 0 +---@type integer +FONT_NOTOSANS_14 = 0 +---@type integer +FONT_NOTOSANS_16 = 0 +---@type integer +FONT_SMALL = 0 +---@type integer +FONT_UI_10 = 0 +---@type integer +FONT_UI_12 = 0 +---@type integer +REFRESH_FAST = 0 +---@type integer +REFRESH_FULL = 0 +---@type integer +REFRESH_HALF = 0 +---@type integer +STYLE_BOLD = 0 +---@type integer +STYLE_NORMAL = 0 +---@type integer +STYLE_REGULAR = 0 + +-- app + +--- Enables the on_tick() callback at a fixed interval (0 disables, minimum 33ms). Requires on_tick() to be defined. +---@param intervalMs integer 0-3600000 +function app.setTickInterval(intervalMs) end + +-- fs + +--- Returns true if a file or directory exists. +---@param path string Absolute path on the SD card +---@return boolean +function fs.exists(path) end + +--- Returns the size of a file in bytes. +---@param path string Absolute path on the SD card +---@return integer? size Nil if the file is missing or a directory +function fs.fileSize(path) end + +--- Lists sorted sub-directory names under a path (hidden entries excluded). +---@param path string Absolute path on the SD card +---@return string[] names +function fs.listDirs(path) end + +--- Lists sorted file names under a path (hidden entries excluded). +---@param path string Absolute path on the SD card +---@return string[] names +function fs.listFiles(path) end + +--- Creates a directory. +---@param path string Absolute path on the SD card +---@return boolean? ok +---@return string? error Present when ok is nil +function fs.mkdir(path) end + +--- Reads an entire file (capped at ~50KB). +---@param path string Absolute path on the SD card +---@return string? content Nil if the file is missing or unreadable +function fs.readFile(path) end + +--- Reads the first full line beginning at or after a byte offset, for paging large files. +---@param path string Absolute path on the SD card +---@param offset integer Byte offset (0-based); snaps forward to the next line boundary if mid-line +---@return string? line Nil past end of file, or if the line exceeds 192 bytes +---@return integer? nextOffset Byte offset of the following line +function fs.readLineAt(path, offset) end + +--- Deletes a file. /.apps and /.crosspoint are protected. +---@param path string Absolute path on the SD card +---@return boolean? ok +---@return string? error Present when ok is nil +function fs.remove(path) end + +--- Recursively deletes a directory tree. /.apps and /.crosspoint are protected. +---@param path string Absolute path on the SD card +---@return boolean? ok +---@return string? error Present when ok is nil +function fs.removeTree(path) end + +--- Renames (moves) a file or directory. +---@param source string +---@param destination string Must not already exist +---@return boolean? ok +---@return string? error Present when ok is nil +function fs.rename(source, destination) end + +--- Writes a file (creating or overwriting). +---@param path string Absolute path on the SD card +---@param content string +---@return boolean ok +function fs.writeFile(path, content) end + +-- gui + +--- Clears the framebuffer to white. +function gui.clear() end + +--- Draws a BMP file from the SD card, centered by default. +---@param path string Absolute path on the SD card +---@param x? integer Left edge (default centered) +---@param y? integer Top edge (default centered) +---@param maxWidth? integer Bounds the image is scaled into (default screen) +---@param maxHeight? integer Bounds the image is scaled into (default screen) +---@return boolean ok False if the file is missing or not a valid BMP +function gui.drawBmp(path, x, y, maxWidth, maxHeight) end + +--- Draws the standard four-button hint bar (use gui.HINT_* for arrow glyphs). +---@param btn1? string Back button label (default "") +---@param btn2? string Confirm button label (default "") +---@param btn3? string Left button label (default "") +---@param btn4? string Right button label (default "") +function gui.drawButtonHints(btn1, btn2, btn3, btn4) end + +--- Draws text centered horizontally on the screen. +---@param font integer FONT_* constant +---@param y integer Baseline Y +---@param text string +---@param color? integer COLOR_* constant (default COLOR_BLACK) +---@param style? integer STYLE_* constant (default STYLE_REGULAR) +function gui.drawCenteredText(font, y, text, color, style) end + +--- Draws an unfilled circle. +---@param cx integer Center X +---@param cy integer Center Y +---@param radius integer +---@param width? integer Stroke width (default 1) +---@param color? integer COLOR_* constant (default COLOR_BLACK) +function gui.drawCircle(cx, cy, radius, width, color) end + +--- Draws a line. +---@param x1 integer Start X +---@param y1 integer Start Y +---@param x2 integer End X +---@param y2 integer End Y +---@param width? integer Stroke width (default 1) +---@param color? integer COLOR_* constant (default COLOR_BLACK) +function gui.drawLine(x1, y1, x2, y2, width, color) end + +--- Draws a single pixel. +---@param x integer +---@param y integer +---@param color? integer COLOR_* constant (default COLOR_BLACK) +function gui.drawPixel(x, y, color) end + +--- Draws an unfilled rectangle. +---@param x integer Left edge +---@param y integer Top edge +---@param w integer Width +---@param h integer Height +---@param color? integer COLOR_* constant (default COLOR_BLACK) +function gui.drawRect(x, y, w, h, color) end + +--- Draws an unfilled rounded rectangle. +---@param x integer Left edge +---@param y integer Top edge +---@param w integer Width +---@param h integer Height +---@param width? integer Stroke width (default 2) +---@param radius? integer Corner radius (default 10) +---@param color? integer COLOR_* constant (default COLOR_BLACK) +function gui.drawRoundedRect(x, y, w, h, width, radius, color) end + +--- Draws text at a position. +---@param font integer FONT_* constant +---@param x integer Left edge +---@param y integer Baseline Y +---@param text string +---@param color? integer COLOR_* constant (default COLOR_BLACK) +---@param style? integer STYLE_* constant (default STYLE_REGULAR) +function gui.drawText(font, x, y, text, color, style) end + +--- Draws a filled circle. +---@param cx integer Center X +---@param cy integer Center Y +---@param radius integer +---@param color? integer COLOR_* constant (default COLOR_BLACK) +function gui.fillCircle(cx, cy, radius, color) end + +--- Draws a filled polygon from parallel vertex arrays (needs 3+ points). +---@param xs int[] X coordinates of each vertex +---@param ys int[] Y coordinates of each vertex +---@param color? integer COLOR_* constant (default COLOR_BLACK) +function gui.fillPolygon(xs, ys, color) end + +--- Draws a filled rectangle. +---@param x integer Left edge +---@param y integer Top edge +---@param w integer Width +---@param h integer Height +---@param color? integer COLOR_* constant (default COLOR_BLACK) +function gui.fillRect(x, y, w, h, color) end + +--- Draws a filled rounded rectangle. +---@param x integer Left edge +---@param y integer Top edge +---@param w integer Width +---@param h integer Height +---@param radius? integer Corner radius (default 10) +---@param color? integer COLOR_* constant (supports gray values) (default COLOR_BLACK) +function gui.fillRoundedRect(x, y, w, h, radius, color) end + +--- Returns the rendered width of a string in pixels. +---@param font integer FONT_* constant +---@param text string +---@param style? integer STYLE_* constant (default STYLE_REGULAR) +---@return integer width +function gui.getTextWidth(font, text, style) end + +--- Returns the screen height in pixels for the current orientation. +---@return integer +function gui.height() end + +--- Pushes the framebuffer to the panel. +---@param mode? integer REFRESH_* constant (default REFRESH_FAST) +function gui.refresh(mode) end + +--- Changes the screen orientation (invalidates any layout assumptions). +---@param mode string "portrait" | "portrait_inv" | "landscape_cw" | "landscape_ccw" +function gui.setOrientation(mode) end + +--- Returns the screen width in pixels for the current orientation. +---@return integer +function gui.width() end + +-- http + +--- Performs an HTTP DELETE request. +---@param url string +---@param headers? table Optional request headers +---@return string? body Nil on failure +---@return integer status HTTP status code, or -1 if the request never happened +function http.delete(url, headers) end + +--- Downloads an HTTPS URL to a file with optional integrity checks. +---@param url string HTTPS URL +---@param destination string Absolute destination path on the SD card +---@param options table { maxBytes: int (required, up to 16MB), expectedSize?: int, sha256?: string (64 hex chars) } +---@return integer? bytesWritten Nil on failure +---@return string? error Present when the download fails +function http.download(url, destination, options) end + +--- Performs an HTTP GET request. +---@param url string +---@param headers? table Optional request headers +---@return string? body Nil on failure (capped at ~50KB) +---@return integer status HTTP status code, or -1 if the request never happened +function http.get(url, headers) end + +--- Performs an HTTP HEAD request. +---@param url string +---@param headers? table Optional request headers +---@return string? body Nil on failure +---@return integer status HTTP status code, or -1 if the request never happened +function http.head(url, headers) end + +--- Performs an HTTP PATCH request. +---@param url string +---@param body? string Request body (default "") +---@param headers? table Optional request headers +---@return string? body Nil on failure (capped at ~50KB) +---@return integer status HTTP status code, or -1 if the request never happened +function http.patch(url, body, headers) end + +--- Performs an HTTP POST request. +---@param url string +---@param body? string Request body (default "") +---@param headers? table Optional request headers +---@return string? body Nil on failure (capped at ~50KB) +---@return integer status HTTP status code, or -1 if the request never happened +function http.post(url, body, headers) end + +--- Percent-encodes a string for use in a URL query. +---@param input string +---@return string encoded +function http.urlencode(input) end + +-- input + +--- Returns true while any button is currently held down. +---@return boolean +function input.isAnyPressed() end + +--- Returns true while the button is currently held down. +---@param button string Button name +---@return boolean +function input.isPressed(button) end + +--- Returns true if the button was pressed since the last input frame. Buttons: "back", "confirm", "left", "right", "up", "down", "page_back", "page_forward". +---@param button string Button name +---@return boolean +function input.wasPressed(button) end + +--- Returns true if the button was released since the last input frame. +---@param button string Button name +---@return boolean +function input.wasReleased(button) end + +-- log + +--- Logs a debug message to serial output. +---@param message string +function log.debug(message) end + +--- Logs an error message to serial output. +---@param message string +function log.error(message) end + +--- Logs an info message to serial output. +---@param message string +function log.info(message) end + +-- sys + +--- Blocks the app for the given number of milliseconds. +---@param ms integer +function sys.delay(ms) end + +--- Requests the app to exit back to the launcher. +function sys.exit() end + +--- Returns milliseconds since boot. +---@return integer +function sys.millis() end + +--- Returns milliseconds since boot. +---@return integer +function sys.uptime() end + +-- timer + +--- Fires on_timer(id) once after the interval. Apps define on_timer(id) to receive it. +---@param intervalMs integer 100-3600000 +---@param id string 1-31 letters, digits, underscores, or dashes +function timer.after(intervalMs, id) end + +--- Cancels a timer by id. +---@param id string +function timer.cancel(id) end + +--- Fires on_timer(id) repeatedly at the interval until cancelled. +---@param intervalMs integer 100-3600000 +---@param id string 1-31 letters, digits, underscores, or dashes +function timer.every(intervalMs, id) end + +-- wifi + +--- Starts connecting to a stored Wi-Fi network (credentials from the reader settings). Poll wifi.status(). +function wifi.connect() end + +--- Disconnects Wi-Fi if the app started it. +function wifi.disconnect() end + +--- Returns whether Wi-Fi is currently connected to an access point. +---@return boolean connected +function wifi.isConnected() end + +--- Returns the local IP address of the Wi-Fi interface. +---@return string ip "0.0.0.0" if not connected +function wifi.localIP() end + +--- Returns the Wi-Fi connection state, advancing any in-progress attempt. +---@return string status "idle" | "connecting" | "connected" | "failed" +function wifi.status() end + +-- Callbacks a runtime app may define: draw(), on_tick(), on_button(name, state), on_timer(id). diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..59f14e9 --- /dev/null +++ b/.luarc.json @@ -0,0 +1,5 @@ +{ + "runtime": { "version": "Lua 5.4" }, + "workspace": { "library": [".luals"] }, + "diagnostics": { "globals": ["app","COLOR_BLACK","COLOR_CLEAR","COLOR_DARK_GRAY","COLOR_LIGHT_GRAY","COLOR_WHITE","FONT_BOOKERLY_12","FONT_BOOKERLY_14","FONT_BOOKERLY_16","FONT_NOTOSANS_12","FONT_NOTOSANS_14","FONT_NOTOSANS_16","FONT_SMALL","FONT_UI_10","FONT_UI_12","fs","gui","http","input","log","REFRESH_FAST","REFRESH_FULL","REFRESH_HALF","STYLE_BOLD","STYLE_NORMAL","STYLE_REGULAR","sys","timer","wifi"] } +} diff --git a/AGENTS.md b/AGENTS.md index 22c3678..674c593 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,13 @@ # AI Agent Guidelines — xteink-apps Lua apps for CrossPoint Reader (Xteink X4, ESP32-C3, 800x480 e-ink). Repo layout, catalog/manifest -formats, and limits are in [README.md](README.md); this file covers the API surface and the rules -that are easy to get wrong. +formats, and limits are in [README.md](README.md); this file covers the rules that are easy to get wrong. -The API below is the contract this repo targets. It is implemented by the CrossPoint Reader firmware -in `src/util/LuaManager.cpp` (bindings) and `src/activities/util/LuaActivity.cpp` (lifecycle); consult -those only if you have that repo checked out and suspect this document has drifted. +**`.luals/crosspoint.lua` is the entire universe available to an app.** It is the LuaLS stub +generated from the firmware bindings — every callable, constant, signature, and doc comment lives +there. Read it for the API; anything absent from it does not exist on device. Regenerate with +`make stubs` (needs the `crosspoint-reader` firmware repo as a sibling checkout; it also refreshes +the `.luarc.json` globals list). ## Non-negotiable: regenerate manifests @@ -14,8 +15,8 @@ Any change to a file under `apps//` changes its SHA-256, and the firmware downloaded file against `manifest.txt` / `catalog.txt`. Stale metadata means installs fail on device. ```bash -scripts/update-manifests.py # regenerate manifests + catalog.txt -scripts/update-manifests.py --check # verify they are current (CI-style) +make manifests # regenerate manifests + catalog.txt +make check # verify they are current (CI-style) ``` Run it before every commit that touches app payloads, and commit payload + `manifest.txt` + @@ -71,90 +72,31 @@ lastScreen = screen gui.refresh(mode) ``` -## API surface +## API gotchas -All globals; no `require`. Full Lua stdlib is open, but prefer the `fs` table over `io`/`os` — -those bypass the firmware's SD mutex. +Signatures live in the stub. These are the behaviors it can't tell you: -### gui - -| Call | Returns | -|---|---| -| `gui.clear()` | – | -| `gui.refresh([mode=REFRESH_FAST])` | – | -| `gui.width()` / `gui.height()` | integer, orientation-aware | -| `gui.setOrientation(mode)` | – ; `"landscape_cw"`, `"landscape_ccw"`, `"portrait_inv"`, anything else = portrait | -| `gui.drawRect(x, y, w, h [, color])` | – | -| `gui.fillRect(x, y, w, h [, color])` | – | -| `gui.drawRoundedRect(x, y, w, h [, lineWidth=2] [, radius=10] [, color])` | – | -| `gui.fillRoundedRect(x, y, w, h [, radius=10] [, color=COLOR_BLACK])` | – ; only call that renders true grayscale | -| `gui.drawLine(x1, y1, x2, y2 [, lineWidth=1] [, color])` | – | -| `gui.drawPixel(x, y [, color])` | – | -| `gui.drawCircle(cx, cy, r [, width=1] [, color])` / `gui.fillCircle(cx, cy, r [, color])` | – | -| `gui.fillPolygon(xTable, yTable [, color])` | – ; 1-based arrays, silently no-ops below 3 points | -| `gui.drawText(fontId, x, y, text [, color] [, style])` | – | -| `gui.drawCenteredText(fontId, y, text [, color] [, style])` | – | -| `gui.getTextWidth(fontId, text [, style])` | px | -| `gui.drawButtonHints([back] [, confirm] [, prev] [, next])` | – ; logical labels, remapped to physical positions | -| `gui.drawBmp(path [, x] [, y] [, maxWidth] [, maxHeight])` | boolean; centered when x/y omitted | - -Constants: `COLOR_CLEAR/WHITE/LIGHT_GRAY/DARK_GRAY/BLACK`, `FONT_UI_10`, `FONT_UI_12`, `FONT_SMALL`, -`FONT_NOTOSANS_12/14/16`, `FONT_BOOKERLY_12/14/16`, `STYLE_REGULAR` (= `STYLE_NORMAL`), `STYLE_BOLD`, -`gui.HINT_BACK/HINT_OK/HINT_PREV/HINT_NEXT`. - -Color args accept a number, or a boolean (`true`=black, `false`=white). Except for -`fillRoundedRect`, gray values collapse to black. - -### input - -`input.wasPressed(name)`, `input.wasReleased(name)`, `input.isPressed(name)`, `input.isAnyPressed()` -→ boolean. - -Names: `"back"`, `"confirm"`, `"left"`, `"right"`, `"up"`, `"down"`, `"page_back"`, `"page_forward"`. -An unrecognized string silently resolves to `back`, so typos are invisible — spell them exactly. - -### fs - -| Call | Returns | -|---|---| -| `fs.exists(path)` | boolean | -| `fs.listDirs(path)` / `fs.listFiles(path)` | sorted array table, dotfiles excluded, empty table if not a dir | -| `fs.readFile(path)` | string (truncated at 50000 B), `nil` on failure | -| `fs.fileSize(path)` | integer or `nil` | -| `fs.readLineAt(path, offset)` | `line, nextOffset`; `nil, nextOffset` if the line exceeds 192 B | -| `fs.writeFile(path, content)` | boolean only — no error string | -| `fs.mkdir(path)` / `fs.rename(src, dst)` / `fs.remove(path)` / `fs.removeTree(path)` | `true` \| `nil, errString` | - -Constraints that bite: +- All globals; no `require`. Full Lua stdlib is open, but prefer `fs` over `io`/`os` — those bypass + the firmware's SD mutex. +- Color args accept a number or a boolean (`true`=black, `false`=white). `gui.fillRoundedRect` is the + only call that renders true grayscale; elsewhere gray collapses to black. +- `gui.fillPolygon` takes 1-based arrays and silently no-ops below 3 points. +- `gui.drawButtonHints` labels are logical; the firmware remaps them to physical positions. +- An unrecognized `input` button name silently resolves to `back`, so typos are invisible. +- `fs.readLineAt` returns `nil, nextOffset` when the line exceeds 192 B; `fs.writeFile` returns a + bare boolean, unlike the other mutators. - **`fs.rename` fails if the destination exists.** Replacing a directory means rename-away, rename-in, then delete the backup (see `install()` in `apps/AppStore/main.lua`). - Mutation calls require an absolute path with no `..` and reject the `/.apps` and `/.crosspoint` roots themselves; paths *under* them are fine. - Stream large files with `fs.readLineAt` rather than `fs.readFile` — 380 KB total RAM. - -### net - -| Call | Returns | -|---|---| -| `net.wifiConnect()` | – ; async, poll `wifiStatus()` | -| `net.wifiStatus()` | `"idle"` \| `"connecting"` \| `"connected"` \| `"failed"` | -| `net.wifiDisconnect()` | – | -| `net.get/head/delete(url [, headers])` | `body \| nil, statusCode` | -| `net.post/patch(url, body [, headers])` | `body \| nil, statusCode` | -| `net.download(url, destPath, options)` | `bytesWritten \| nil, errString` | -| `net.urlencode(s)` | string | - - Never block on Wi-Fi: enter a `"connecting"` screen and poll from `draw()`. - Response bodies are capped at 50000 B and returned only for 2xx; status is `-1` on client failure. - `net.download` **requires** an options table; keys are `maxBytes` (required), `expectedSize`, `sha256` (64 hex). An unknown key is an error. It verifies size and hash for you — use it for anything installed to disk. - TLS does not verify certificates. Treat downloaded content as untrusted; validate before use. - -### sys / log - -`sys.millis()`, `sys.uptime()`, `sys.delay(ms)`, `sys.exit()`. -`log.debug(msg)`, `log.info(msg)`, `log.error(msg)` — single string argument, no format args. +- `log.*` take a single string — no format args. ## Style @@ -163,3 +105,11 @@ Constraints that bite: with no line numbers. - Collect garbage (`collectgarbage("collect")`) before large allocations such as a catalog parse. - Match the existing 4-space indentation and the `if cond then return end` single-line guard style. + +## Keeping this file honest + +When a change makes something here wrong — a workflow, a constraint, a gotcha that no longer bites — +update it in the same commit. A stale rule is worse than no rule. + +That obligation covers corrections only. Anything new (a convention you'd like to establish, a +section you think is missing) gets proposed to the user first; this file stays short by default. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4d5c1de --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +CROSSPOINT ?= ../crosspoint-reader +STUB := .luals/crosspoint.lua + +.PHONY: manifests check stubs + +manifests: + scripts/update-manifests.py + +check: + scripts/update-manifests.py --check + +# Regenerates the LuaLS stub from the firmware bindings in the sibling repo, +# then rewrites .luarc.json globals from whatever the stub declares. +stubs: + @test -f $(CROSSPOINT)/scripts/gen_lua_stubs.py || { \ + echo "ERROR: firmware repo not found at $(CROSSPOINT)"; \ + echo " clone crosspoint-reader beside this repo, or run: make stubs CROSSPOINT=/path/to/crosspoint-reader"; \ + exit 1; } + cd $(CROSSPOINT) && uv run scripts/gen_lua_stubs.py + cp $(CROSSPOINT)/data/lua/crosspoint.lua $(STUB) + @globals=$$(sed -nE 's/^([A-Za-z_][A-Za-z0-9_]*) = (\{\}|0)$$/\1/p; s/^function ([A-Za-z_][A-Za-z0-9_]*)\(.*/\1/p' $(STUB) | sort -u | sed 's/.*/"&"/' | paste -sd, -); \ + printf '{\n "runtime": { "version": "Lua 5.4" },\n "workspace": { "library": [".luals"] },\n "diagnostics": { "globals": [%s] }\n}\n' "$$globals" > .luarc.json + @echo "Updated $(STUB) and .luarc.json" diff --git a/flake.nix b/flake.nix index 342a083..fd49d01 100644 --- a/flake.nix +++ b/flake.nix @@ -6,10 +6,15 @@ flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { nixpkgs, flake-utils, ... }: + outputs = + { nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system: { devShells.default = nixpkgs.legacyPackages.${system}.mkShell { - packages = with nixpkgs.legacyPackages.${system}; [ git uv ]; + packages = with nixpkgs.legacyPackages.${system}; [ + git + uv + gnumake + ]; }; }); }