← App Store

App Store · Lua 5.4

Write apps for Solana OS

Badge apps are Lua programs: main.lua, plus any modules and data files they bring along, with a small, sandboxed API for the screen, buttons, LEDs, storage and the game. Write one, try it in the emulator or on your badge, publish it.

#Solana OS Lua apps

Badge apps from the App Store are Lua 5.4 programs that run in a sandbox on Solana OS: main.lua, plus any modules and data files the app brings along. This file is the API reference. The web shows it at /apps/docs, the firmware implements it (firmware/main/lua/) and the server enforces the permissions.

#The package

An app is a folder. main.lua (UTF-8, ≤ 64 KB) runs first, and app.json is the manifest, which you fill in when you submit the app on the web (a folder or .zip you upload can bring one along to fill in the form). Every other file in the folder ships with the app: Lua modules for require, and data of any kind (text, JSON, binary) for fs. They stay on the microSD card until the app reads them, so a big app only spends memory on what it has loaded.

trivia/
  main.lua              runs first
  app.json              the manifest
  lib/quiz.lua          require("lib.quiz")
  questions/space.txt   fs.lines("questions/space.txt")

Bundled files: at most 64 besides main.lua and app.json, each ≤ 256 KB, ≤ 1 MB together. Paths use lowercase letters, digits, _, - and ., with / between folders: ≤ 64 characters, ≤ 4 levels, and no name starts or ends with .. The App Store installs an app into /sdcard/hh/apps/<id>/: main.lua, app.json, the app's storage in data.json, and the bundled files under files/. An update downloads only the files that changed, then swaps the whole folder in once everything is there, keeping data.json.

Apps that quests hand out through a beacon are a main.lua only: beacons can't carry bundled files.

The manifest, as the badge stores it:

{
  "id": "snake",                    // 3-24 chars: a-z 0-9 -, unique in the store
  "name": "Snake",                  // ≤ 20 chars, shown in the launcher list
  "version": "1.0.0",
  "author": "@toly",
  "summary": "Eat, grow, don't bite yourself.",
  "icon": "play",                   // one of the icon names below
  "accent": "14F195",               // hex colour, no '#'
  "category": "games",              // games | tools | social | quests | demos
  "permissions": ["lamps", "items"],
  "files": 2,                       // bundled files (the store fills it in)
  "filesSize": 5120,                // their bytes together
  "temp": false,                    // true for quest-granted apps
  "expires": null                   // unix ms when a temporary app stops working
}

Icons: play, star, heart, bell, gift, home, image, list, wifi, shuffle, eye, edit, charge, gps, settings, audio, bulb (the badge maps them to LVGL symbols; the web to matching glyphs).

#Lifecycle

The host loads main.lua, runs the top-level chunk once (build your UI there), then calls these globals if you define them:

function on_key(key, rep)    -- "up" "down" "left" "right" "select" "back"; rep = auto-repeat
  return true                -- handled. Returning false/nil for "back" exits the app.
end
function on_tick(ms)         -- every 50 ms, ms = time since the last tick
end
function on_mesh(from, msg)  -- a message from the same app on another badge (needs "mesh")
end
function on_exit()           -- the app is closing (HOME, CANCEL, sys.exit, error)
end

HOME always returns to the launcher. The screen below the status bar is 320 × 218 pixels (ui.W, ui.H), with (0, 0) at the top-left.

#Limits

Memory48 KB of Lua heap (the app is stopped with "Out of memory" beyond that)
CPU200 000 VM instructions per callback, coroutines and pattern matching included (else "Script took too long")
UI objects64 at a time
Text200 bytes per label (longer text ends in "..."), 6 KB of label text on screen
Timers8 at a time, ≥ 20 ms
Storage16 KB per app (storage.*)
Files64 bundled files, ≤ 256 KB each, ≤ 1 MB together, read-only (fs, require)
Reads≤ 16 KB per fs.read and fs.json, lines ≤ 512 bytes. Card reads count toward the instruction budget: 500 per file opened or looked up (fs.exists, fs.size, fs.list), 1 per 4 bytes read or compiled
Mesh4 messages per second, ≤ 200 bytes each
Numbers32-bit integers (they wrap at ±2^31) and 32-bit floats (about 7 digits)

A runtime error stops the app and shows the message with the line number. CANCEL returns to the launcher. print(...) goes to the badge's serial console.

#Callbacks and network calls

Anything that talks to the server is asynchronous and takes a callback cb(ok, result): ok is a boolean, and result is the value or an error string ("offline" when Wi-Fi is off, "not linked" when the badge has no owner, "denied" when the app lacks the permission or the user said no). Callbacks run like any other callback: between on_ticks, never concurrently.

#Standard library

Available: assert error ipairs next pairs pcall select tonumber tostring type xpcall print getmetatable setmetatable rawequal rawget rawlen rawset _G _VERSION, string.* (no string.dump), table.*, math.* (with math.random seeded per launch), utf8.*, coroutine.*, and require for the app's own modules (see fs). Removed: io, os, debug, package, load/loadfile/dofile (fs.load compiles the app's own files), warn, collectgarbage (except collectgarbage("count")).

#Modules

#ui

ui.W, ui.H                          -- 320, 218
ui.bg(color)                        -- background of the app area
local l = ui.label(x, y, text, {size=14, color="text", mono=false, w=nil, align="left"})
--   size: 12 | 14 | 20 | 32 (12 is always mono), align: "left" | "center" | "right" (needs w)
l:text("hi")  l:pos(x, y)  l:color("green")  l:show(false)  l:remove()
local r = ui.rect(x, y, w, h, {color="panel", radius=0, border=nil})
r:pos(x, y)  r:size(w, h)  r:color(c)  r:show(b)  r:remove()
local b = ui.bar(x, y, w, h, value)     -- progress 0..100, Solana gradient fill
b:set(value)  b:remove()
local g = ui.gradient(x, y, w, h)       -- the Solana gradient
local list = ui.list(x, y, w, h, items, {on_select=function(i, item) end})
--   items: { "text", ... } or { {title=, sub=, right=}, ... }; the list takes up/down
--   rows are 36 px; long text ends in "..."
list:items(newItems)  list:selected() --> index  list:select(i)  list:remove()
ui.hint("SELECT jump   CANCEL exit")    -- key hint pinned to the bottom (16 px)
ui.toast("Saved")
ui.confirm("Reset the score?", function(yes) end)      -- modal, SELECT = yes, CANCEL = no
ui.input("Your name", 16, function(text) end)          -- d-pad keyboard; text = nil if cancelled
ui.clear()                              -- remove every object

Colours are theme names: "text" "dim" "bg" "panel" "border" "purple" "green" "pink" "lpurple" "blue" "aqua" "lime" "yellow" "orange". They follow the Dark/Light theme. A number 0xRRGGBB is a fixed colour. While a list, ui.confirm or ui.input has focus it consumes the keys it uses before on_key sees them.

#led (permission led)

led.count                   -- 8 (1..6 across the top, 7..8 on the right edge)
led.set(i, r, g, b)         -- 0..255 each; the OS scales to its brightness cap
led.hex(i, 0x9945FF)
led.fill(r, g, b)
led.off()

Changes show at the end of the current callback. LEDs turn off when the app exits.

#timer

local id = timer.after(ms, fn)
local id = timer.every(ms, fn)
timer.cancel(id)

#sys, badge, json

sys.millis()                -- ms since the app started
sys.time()                  -- unix seconds from the server clock, or nil if never synced
sys.battery()               -- percent, or nil on USB / unknown
sys.exit()                  -- close the app
badge.id()                  -- "7K2Q9M", or nil before the first hello
badge.owner()               -- "@toly", or nil while unclaimed
badge.online()              -- Wi-Fi joined and registered with the server
json.encode(value)  json.decode(text)   -- nil plus an error message on bad input

#storage (always allowed)

Private key/value storage on the SD card, kept across launches and removed with the app.

storage.get("best")         -- value or nil
storage.set("best", 42)     -- numbers, strings, booleans, tables (JSON-encodable)
storage.remove("best")

#fs and require (always allowed)

The app's bundled files (see "The package"), read-only. Paths are the ones in your folder, like "questions/space.txt": no leading / and no ... An app sees only its own files; main.lua, app.json and data.json aren't among them.

local quiz = require("lib.quiz")      -- runs lib/quiz.lua once; later calls return the same value
fs.exists("questions")                -- true for a file or a folder
fs.size("questions/space.txt")        -- bytes, or nil if there's no such file
fs.list("questions")                  -- { "art.txt", "extra/", "space.txt" }: sorted, folders end in "/"
fs.list()                             -- the top of the app's files; {} for a folder that isn't there
fs.read("intro.txt")                  -- the whole file (≤ 16 KB) as a string, or nil, "not found"
fs.read("book.txt", 8192, 1024)       -- 1024 bytes from byte 8192 (counting from 0); "" past the end
for line in fs.lines("words.txt") do  -- each line without its "\n" or "\r\n"; ≤ 512 bytes,
end                                   -- longer lines come in pieces
fs.json("levels/1.json")              -- the decoded value (file ≤ 16 KB), or nil and an error
local scene = fs.load("scenes/intro.lua")  -- compiled, not run or kept: scene() runs it;
                                           -- nil and an error for a syntax error

require("lib.quiz") loads lib/quiz.lua: dots separate folders. The module runs with the same globals as main.lua, and what it returns (usually a table, true if nothing) is what every require of that name gets. A missing module or an error in one stops the app like any other error; wrap it in pcall to handle it. A missing file is an error for fs.lines too, while fs.read, fs.json and fs.load return nil and a message. A bad path, or asking for more than 16 KB at once, is an error.

Everything you read lands in the app's 48 KB heap, so keep only what you use: read big files in pieces (offsets or fs.lines), and load data when the app needs it. require keeps a module until the app exits. For code you need only for a while (a level, a scene), use fs.load and drop the function and what it returned once you're done: the collector frees them. Reading also counts toward the instruction budget (see Limits): 100 KB costs 25 000 of the 200 000 a callback gets. The file read last stays open, so reading on in it (pages, lines) doesn't pay to open it again.

#wifi (permission wifi)

wifi.status()               -- "off" | "connecting" | "on" | "failed"
wifi.ssid()                 -- saved network name or nil
wifi.on()                   -- join the saved network
wifi.off()

#mesh (permission mesh)

Opening an app with mesh puts the radio on the mesh channel while it runs.

mesh.nearby()               -- { {id="7K2Q9M", name="@toly", level=12, rssi=-48, kind="badge"}, ... }
mesh.send(to, msg)          -- to = badge id or "*"; msg = table (≤ 200 bytes as JSON)
-- received by on_mesh(from_id, msg) on badges running the same app

#lamps (permission lamps)

lamps.balance(cb)                       -- cb(true, 1250)
lamps.spend(amount, sku, cb)            -- in-app purchase paid to the app's author.
                                        -- The OS asks "Pay 50 !LAMPs to <App> for <sku>?"
                                        -- cb(true, {balance=1200}) | cb(false, "denied")
lamps.transfer(badgeId, amount, memo, cb) -- pay another badge's owner (OS confirm dialog too)
lamps.deposit(address, amount, memo, cb) -- pay into one of the author's escrows (OS confirm too):
                                        -- "Pay 50 !LAMPs into escrow ESC-7K2Q9M3X of <App>?"
                                        -- cb(true, {balance=1150, id="4821"})  (Solana OS 0.3.1)
                                        -- spend + transfer + deposit: at most 500 !LAMPs a day per user
lamps.award(amount, reason, cb)         -- earn: only within the app's daily award cap,
                                        -- cb(true, {awarded=10, balance=1260})

#Escrows

lamps.spend pays the author's own balance. To hold !LAMPs for players instead (a wager's pot, buy-ins, a jackpot, a bank the app pays winners from), create an escrow at /apps/escrows: a payment address like ESC-7K2Q9M3X with a balance of its own, apart from yours. Put the address in your app and pay into it with lamps.deposit; it only accepts escrows that belong to the app's author. memo (≤ 64 characters) is how your app says what a deposit is for ("round 42 red").

Paying out is up to your own server, with the escrow's key (shown once when you make the escrow; you can rotate it): it polls the escrow's ledger for new deposits (who paid, from which badge and app, the memo), decides, and pays players back, winners, or you, by badge id or user id. The badge never holds the key, so nobody can pay themselves out of your escrow by tampering with a badge. The endpoints are in the server API (/api/escrow, /api/escrow/ledger, /api/escrow/pay):

curl -H "Authorization: Bearer $ESCROW_KEY" "https://<server>/api/escrow/ledger?after=0"
curl -H "Authorization: Bearer $ESCROW_KEY" -H "content-type: application/json" \
  -d '{"to":"7K2Q9M","amount":100,"memo":"won round 42","key":"round-42"}' https://<server>/api/escrow/pay

Payouts show in the player's wallet as "Escrow payout" with your memo. key makes a payout safe to retry. Your apps can't reach your server directly, so put what the server needs in the memo, or in items the app grants.

#items (permission items)

Items are JSON objects in the owner's inventory. An app can create and change only its own items, and it can read everything. An app granted by a quest may also update the items that quest gave while the grant is live (e.g. Virus Lab mutating the quest's virus), and items.list includes them. Lists hold the newest 50.

items.list(cb)              -- the items this app may update: { {id=, kind=, name=, data={...}, tradable=true}, ... }
items.all(cb)               -- every item the owner has (read-only for other apps' items)
items.grant(kind, name, data, cb)        -- new item; data ≤ 1 KB as JSON; cb(true, item)
items.update(id, data, cb)               -- replace data of one of this app's items
items.remove(id, cb)

The server rate-limits grants (20 per user per app per hour) and caps an app at 200 live items per user.

#profile (permission profile)

profile.me(cb)              -- {name=, x=, level=, lamps=, pages=, badge=}
profile.get(badgeId, cb)    -- public profile of that badge's owner: {name=, x=, level=}

#quest (permission quest)

For apps that quests hand out.

quest.report(event, data, cb)   -- tell the quest engine something happened, e.g.
                                -- quest.report("mutated", {strain="omega"})
quest.active(cb)                -- the owner's active quests: { {id=, title=, step=}, ... }

#Example

-- Tap counter that pays 1 !LAMP per 100 taps (needs an award cap from an admin).
local taps = storage.get("taps") or 0
local title = ui.label(0, 60, "Taps", {size=20, w=ui.W, align="center"})
local count = ui.label(0, 100, tostring(taps), {size=32, w=ui.W, align="center", color="green"})
ui.hint("SELECT tap   CANCEL exit")

function on_key(k)
  if k ~= "select" then return false end
  taps = taps + 1
  count:text(tostring(taps))
  led.fill(0x14, 0xF1, 0x95)
  timer.after(80, led.off)
  if taps % 100 == 0 then
    lamps.award(1, "100 taps", function(ok, r) if ok then ui.toast("+1 !LAMP") end end)
  end
  return true
end

function on_exit() storage.set("taps", taps) end

And one with files: flashcards, one deck per file, loaded when you pick it.

-- decks/*.txt: "question|answer" per line. lib/cards.lua: function M.parse(line) -> q, a
local cards = require("lib.cards")
local names = {}
for _, f in ipairs(fs.list("decks")) do
  if f:sub(-4) == ".txt" then names[#names + 1] = f:sub(1, -5) end
end
local card = ui.label(10, 80, "", {size=20, w=300, align="center"})
local deck, i, flipped
local menu                                      -- declared first: its callback uses it

menu = ui.list(0, 0, ui.W, ui.H - 16, names, {on_select=function(_, name)
  deck, i, flipped = {}, 1, false
  for line in fs.lines("decks/" .. name .. ".txt") do
    local q, a = cards.parse(line)
    if q then deck[#deck + 1] = {q, a} end
  end
  menu:show(false)
  card:text(deck[1] and deck[1][1] or "Empty deck")
  ui.hint("SELECT flip   RIGHT next   CANCEL decks")
end})
ui.hint("SELECT open   CANCEL exit")

function on_key(k)
  if not deck then return false end            -- on the deck list, CANCEL exits
  if k == "back" then
    deck = nil                                  -- the collector frees the cards
    card:text("")
    menu:show(true)
    ui.hint("SELECT open   CANCEL exit")
  elseif k == "select" and deck[i] then
    flipped = not flipped
    card:text(deck[i][flipped and 2 or 1])
  elseif k == "right" and deck[i + 1] then
    i, flipped = i + 1, false
    card:text(deck[i][1])
  end
  return true
end

#Develop and publish

  • Write the app in the editor on /apps/submit, or drop a folder or .zip on it: main.lua, app.json if you have one (it fills in the form) and the files it needs. New text files can be made right there.
  • Try in emulator runs it on an emulated badge in the page, with every file on the emulated card. print() and errors show below it.
  • Run on badge copies it onto a real badge over USB (desktop Chrome or Edge, Web Serial), sending only the files that changed, starts it and shows the badge's console, print() included. The badge needs a microSD card. It replaces an installed app with the same id.
  • Publish puts it in the store. Each update is a new version; the badge offers it as an update and downloads only the files that changed.

#Console (development)

lua list, lua run <id>, lua rm <id> [file] on the badge console (and in the emulator via emu_console). The web submit page uses lua run to test an app in the emulator. lua mem shows the last run's peak heap and instruction count, and lua install <id> installs or updates an app from the App Store, as the store app does.

To copy an app over the serial console, <file> is main.lua, app.json or a bundled path like lib/quiz.lua:

  • lua put <id> <file> begin|<base64>|end writes a file a line at a time (at most 128 base64 characters per line), easy to type or script.
  • lua recv <id> <file> <bytes> <sha256> is the fast way, for tools. The badge answers recv: ready <n>: send the next n characters of the file's base64 (the rest, at the end) and wait for recv: more <n> before sending more, so the card is never written while data is arriving. It ends with recv: ok <bytes> once the size and SHA-256 match, or recv: error <why> (5 s without data is a timeout), keeping the old file.
  • lua sums <id> prints <sha256> <bytes> <file> for each file of an installed app, then sums: <n> files (0 if it isn't installed), so a tool can skip what's already there. Run on badge works this way.

These refuse while the app runs (open home closes it) and check app.json like an install does.