ElijahCrafter

OS DEV Build

Jul 16th, 2026 (edited)
61
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Lua 239.03 KB | Source Code | 0 0
  1. -- PocketOS installer
  2. -- Run this on an advanced pocket computer to install PocketOS.
  3. -- It writes all OS files, then reboots into PocketOS.
  4. -- Existing music, messages, scores are kept. config.lua keeps your
  5. -- values but gains any new keys this version added.
  6.  
  7. local files = {
  8. ["startup.lua"] = [==[
  9. -- PocketOS bootstrap
  10. -- Requires an ADVANCED (gold) pocket computer for colour + touch.
  11.  
  12. -- Dedicated roles: the configured tracker hub or bank server boots
  13. -- into that program instead of PocketOS.
  14. do
  15.     local ok, cfg = pcall(dofile, "/pocketos/config.lua")
  16.     if ok and type(cfg) == "table" then
  17.         if tonumber(cfg.tracker) == os.getComputerID()
  18.             and fs.exists("tracker.lua") then
  19.             shell.run("tracker.lua")
  20.             return
  21.         end
  22.         if tonumber(cfg.bank) == os.getComputerID()
  23.             and fs.exists("bank.lua") then
  24.             shell.run("bank.lua")
  25.             return
  26.         end
  27.     end
  28. end
  29.  
  30. if not term.isColor or not term.isColor() then
  31.     print("PocketOS needs an *advanced* (gold) pocket computer.")
  32.     return
  33. end
  34.  
  35. local ok, err = pcall(function()
  36.     shell.run("/pocketos/main.lua")
  37. end)
  38.  
  39. if not ok and err and not tostring(err):match("Terminated") then
  40.     term.setBackgroundColor(colors.black)
  41.     term.setTextColor(colors.white)
  42.     term.clear()
  43.     term.setCursorPos(1, 1)
  44.     print("PocketOS crashed:")
  45.     print(tostring(err))
  46.     print("")
  47.     print("Press any key to reboot.")
  48.     os.pullEvent("key")
  49.     os.reboot()
  50. end
  51. ]==],
  52. ["tracker.lua"] = [==[
  53. -- PocketOS tracker hub.
  54. -- Shows a live table of every PocketOS device reporting its location.
  55. -- The computer whose ID matches 'tracker' in /pocketos/config.lua boots
  56. -- into this automatically. Needs a wireless modem attached.
  57.  
  58. local devices = {}
  59. local hasModem = false
  60. for _, side in ipairs(peripheral.getNames()) do
  61.     if peripheral.getType(side) == "modem" then
  62.         if pcall(rednet.open, side) then hasModem = true end
  63.     end
  64. end
  65. if hasModem then
  66.     rednet.host("pocketos.track", "tracker" .. os.getComputerID())
  67. end
  68.  
  69. -- admin safety: only these IDs may pull the fleet location list
  70. local admins = { 11, 18 }
  71. do
  72.     local ok, cfg = pcall(dofile, "/pocketos/config.lua")
  73.     if ok and type(cfg) == "table" and type(cfg.admins) == "table" then
  74.         admins = cfg.admins
  75.     end
  76. end
  77. local function isAdmin(id)
  78.     for _, a in ipairs(admins) do
  79.         if tonumber(a) == tonumber(id) then return true end
  80.     end
  81.     return false
  82. end
  83.  
  84. local color = term.isColor and term.isColor()
  85. local function tint(c)
  86.     if color then term.setTextColor(c) end
  87. end
  88.  
  89. local function draw()
  90.     term.setBackgroundColor(colors.black)
  91.     term.setTextColor(colors.white)
  92.     term.clear()
  93.     local w, h = term.getSize()
  94.     term.setCursorPos(1, 1)
  95.     tint(colors.yellow)
  96.     write("PocketOS Tracker - computer #" .. os.getComputerID())
  97.     if not hasModem then
  98.         term.setCursorPos(1, 2)
  99.         tint(colors.red)
  100.         write("No modem attached!")
  101.     end
  102.     term.setCursorPos(1, 3)
  103.     tint(colors.lightGray)
  104.     write(("%-4s %-12s %6s %4s %6s %s"):format("ID", "Name", "X", "Y", "Z", "Seen"))
  105.  
  106.     local ids = {}
  107.     for id in pairs(devices) do ids[#ids + 1] = id end
  108.     table.sort(ids)
  109.     tint(colors.white)
  110.     local y = 4
  111.     for _, id in ipairs(ids) do
  112.         if y > h - 1 then break end
  113.         local d = devices[id]
  114.         local age = math.floor(os.clock() - d.seen)
  115.         term.setCursorPos(1, y)
  116.         write(("%-4d %-12s %6d %4d %6d %ds"):format(
  117.             id, (d.label or "?"):sub(1, 12), d.x, d.y, d.z, age))
  118.         y = y + 1
  119.     end
  120.     if #ids == 0 then
  121.         term.setCursorPos(1, 5)
  122.         tint(colors.gray)
  123.         write("Waiting for reports...")
  124.         term.setCursorPos(1, 7)
  125.         write("On each pocket, set tracker = " .. os.getComputerID())
  126.         term.setCursorPos(1, 8)
  127.         write("in /pocketos/config.lua")
  128.     end
  129.     term.setCursorPos(1, h)
  130.     tint(colors.gray)
  131.     write("Hold Ctrl+T to exit")
  132. end
  133.  
  134. local timer = os.startTimer(1)
  135. draw()
  136. while true do
  137.     local e, a, b, c = os.pullEvent()
  138.     if e == "rednet_message" then
  139.         local id, msg, proto = a, b, c
  140.         if proto == "pocketos.track" and type(msg) == "table" then
  141.             if msg.type == "query" then
  142.                 -- a developer's Fleet screen asking for all positions
  143.                 if isAdmin(id) then
  144.                     local list = {}
  145.                     for did, d in pairs(devices) do
  146.                         list[#list + 1] = {
  147.                             id = did, label = d.label, x = d.x, y = d.y, z = d.z,
  148.                             age = math.floor(os.clock() - d.seen),
  149.                         }
  150.                     end
  151.                     rednet.send(id, { ok = true, devices = list }, "pocketos.track")
  152.                 else
  153.                     rednet.send(id, { ok = false, err = "Not an admin" }, "pocketos.track")
  154.                 end
  155.             elseif type(msg.x) == "number" and type(msg.y) == "number"
  156.                 and type(msg.z) == "number" then
  157.                 devices[id] = {
  158.                     label = type(msg.label) == "string" and msg.label or nil,
  159.                     x = math.floor(msg.x),
  160.                     y = math.floor(msg.y),
  161.                     z = math.floor(msg.z),
  162.                     seen = os.clock(),
  163.                 }
  164.                 draw()
  165.             end
  166.         end
  167.     elseif e == "timer" and a == timer then
  168.         draw()
  169.         timer = os.startTimer(1)
  170.     end
  171. end
  172. ]==],
  173. ["gpshost.lua"] = [==[
  174. -- PocketOS helper: turn this computer into a GPS host.
  175. --
  176. -- Minecraft has no built-in GPS. A "GPS cluster" is 4+ computers with
  177. -- wireless modems, spread out (high up for range, a few blocks apart,
  178. -- and NOT all at the same height), each running as a gps host. Pocket
  179. -- computers in radio range can then work out where they are.
  180. --
  181. -- Run this on each host computer and type in that computer's own
  182. -- block coordinates (press F3 in-game to read them off).
  183.  
  184. local hasModem = false
  185. for _, side in ipairs(peripheral.getNames()) do
  186.     if peripheral.getType(side) == "modem"
  187.         and peripheral.call(side, "isWireless") then
  188.         hasModem = true
  189.     end
  190. end
  191. if not hasModem then
  192.     print("Attach a wireless modem to this computer first!")
  193.     return
  194. end
  195.  
  196. print("This computer's block coordinates (press F3 to see them):")
  197. write("x: ")
  198. local x = tonumber(read())
  199. write("y: ")
  200. local y = tonumber(read())
  201. write("z: ")
  202. local z = tonumber(read())
  203. if not (x and y and z) then
  204.     print("Those need to be numbers - run gpshost again.")
  205.     return
  206. end
  207.  
  208. local f = fs.open("startup.lua", "w")
  209. f.write("-- GPS host (created by gpshost)\n")
  210. f.write(("shell.run(\"gps\", \"host\", %s, %s, %s)\n"):format(x, y, z))
  211. f.close()
  212.  
  213. print(("GPS host at %s, %s, %s saved."):format(x, y, z))
  214. print("This computer now serves GPS on every boot.")
  215. sleep(1.5)
  216. os.reboot()
  217. ]==],
  218. ["doorlock.lua"] = [==[
  219. -- doorlock - a touch PIN lock for an ADVANCED computer (not pocket).
  220. --
  221. -- First run: pick a PIN on the keypad; the program takes over startup
  222. -- so the computer boots locked from then on. Ctrl+T is blocked, and
  223. -- attempts/lockout survive reboots.
  224. --
  225. -- 3 wrong PINs = locked out. The unlock code is rednet-messaged to the
  226. -- pocket computer OWNER_ID (it lands in the Messages app) - the door
  227. -- computer needs a wireless modem for that. With no modem it falls
  228. -- back to a 60 second cooldown.
  229. --
  230. -- When unlocked: big OPEN DOOR button (your redstone goes in
  231. -- openDoor() below), plus Lock and Shell buttons in the corners.
  232.  
  233. -------------------------------------------------- CONFIG - edit me --
  234.  
  235. local OWNER_ID = 11      -- pocket computer that receives unlock codes
  236. local DOOR_SIDE = "bottom" -- redstone side used by the default openDoor
  237. local DOOR_TIME = 3      -- seconds the door stays powered
  238. local AUTO_LOCK = 60     -- relock after this many idle seconds (0 = never)
  239. local door = peripheral.find("redstoneIntegrator")
  240. -- The OPEN DOOR button runs this. Put YOUR redstone logic here.
  241. local function openDoor()
  242.     door.setOutput(DOOR_SIDE, true)
  243.     sleep(DOOR_TIME)
  244.     door.setOutput(DOOR_SIDE, false)
  245. end
  246.  
  247. -----------------------------------------------------------------------
  248.  
  249. local DATA = "/.doorlock"
  250.  
  251. if not term.isColor or not term.isColor() then
  252.     print("doorlock needs an advanced (gold) computer.")
  253.     return
  254. end
  255.  
  256. math.randomseed(os.epoch and os.epoch("utc") or os.time() * 1000)
  257.  
  258. local hasModem = false
  259. for _, side in ipairs(peripheral.getNames()) do
  260.     if peripheral.getType(side) == "modem" then
  261.         if pcall(rednet.open, side) then hasModem = true end
  262.     end
  263. end
  264.  
  265. local w, h = term.getSize()
  266.  
  267. local function loadState()
  268.     if fs.exists(DATA) then
  269.         local f = fs.open(DATA, "r")
  270.         local v = textutils.unserialize(f.readAll() or "")
  271.         f.close()
  272.         if type(v) == "table" then return v end
  273.     end
  274.     return {}
  275. end
  276.  
  277. local st = loadState()
  278.  
  279. local function save()
  280.     local f = fs.open(DATA, "w")
  281.     f.write(textutils.serialize(st))
  282.     f.close()
  283. end
  284.  
  285. local function hash(s)
  286.     local n = 5381
  287.     for i = 1, #s do
  288.         n = (n * 33 + s:byte(i)) % 4294967296
  289.     end
  290.     return tostring(n)
  291. end
  292.  
  293. local function fill(x, y, wd, ht, bg)
  294.     term.setBackgroundColor(bg)
  295.     for i = 0, ht - 1 do
  296.         term.setCursorPos(x, y + i)
  297.         term.write(string.rep(" ", wd))
  298.     end
  299. end
  300.  
  301. local function center(y, s, fg)
  302.     term.setCursorPos(math.floor((w - #s) / 2) + 1, y)
  303.     term.setTextColor(fg)
  304.     term.setBackgroundColor(colors.black)
  305.     term.write(s)
  306. end
  307.  
  308. local function sendCode(code)
  309.     if not hasModem then return false end
  310.     rednet.send(OWNER_ID, {
  311.         text = "3 wrong PIN tries on #" .. os.getComputerID()
  312.             .. ". Unlock code: " .. code,
  313.         label = os.getComputerLabel() or "Door Lock",
  314.     }, "pocketos")
  315.     return true
  316. end
  317.  
  318. -- GUI number pad. Returns the digits entered once OK/Enter is pressed.
  319. -- opts: hint (bottom line), onResend (adds a resend-code button).
  320. local function keypad(title, msg, opts)
  321.     opts = opts or {}
  322.     local entry = ""
  323.     local info = msg
  324.     local infoColor = colors.red
  325.     local layout = {
  326.         { "1", "2", "3" },
  327.         { "4", "5", "6" },
  328.         { "7", "8", "9" },
  329.         { "C", "0", "OK" },
  330.     }
  331.     while true do
  332.         term.setBackgroundColor(colors.black)
  333.         term.clear()
  334.         center(2, title, colors.white)
  335.         if #entry > 0 then
  336.             center(4, string.rep("\7 ", #entry), colors.blue)
  337.         else
  338.             center(4, "_ _ _", colors.gray)
  339.         end
  340.         local regions = {}
  341.         local x0 = math.floor(w / 2) - 8
  342.         for r = 1, 4 do
  343.             for cn = 1, 3 do
  344.                 local label = layout[r][cn]
  345.                 local bx = x0 + (cn - 1) * 6
  346.                 local by = 4 + r * 2
  347.                 fill(bx, by, 5, 1, label == "OK" and colors.green or colors.gray)
  348.                 term.setCursorPos(bx + math.floor((5 - #label) / 2), by)
  349.                 term.setTextColor(colors.white)
  350.                 term.write(label)
  351.                 regions[#regions + 1] = { x1 = bx, x2 = bx + 4, y = by, label = label }
  352.             end
  353.         end
  354.         if info then center(14, info, infoColor) end
  355.         if opts.onResend then
  356.             center(16, "[ Resend code ]", colors.blue)
  357.         end
  358.         center(h, opts.hint or "Typing digits works too", colors.gray)
  359.  
  360.         local function press(label)
  361.             if label == "OK" then
  362.                 if #entry >= 3 then return entry end
  363.                 info, infoColor = "At least 3 digits", colors.red
  364.             elseif label == "C" then
  365.                 entry = ""
  366.             elseif #entry < 8 then
  367.                 entry = entry .. label
  368.             end
  369.         end
  370.  
  371.         local e, a, b, c = os.pullEventRaw() -- raw: Ctrl+T is ignored
  372.         if e == "mouse_click" then
  373.             local handled = false
  374.             for _, r in ipairs(regions) do
  375.                 if c == r.y and b >= r.x1 and b <= r.x2 then
  376.                     local done = press(r.label)
  377.                     if done then return done end
  378.                     handled = true
  379.                     break
  380.                 end
  381.             end
  382.             if not handled and opts.onResend and c == 16 then
  383.                 opts.onResend()
  384.                 info, infoColor = "Code sent again!", colors.green
  385.             end
  386.         elseif e == "char" and a:match("^%d$") then
  387.             if #entry < 8 then entry = entry .. a end
  388.         elseif e == "key" then
  389.             if a == keys.enter then
  390.                 if #entry >= 3 then return entry end
  391.                 info, infoColor = "At least 3 digits", colors.red
  392.             elseif a == keys.backspace then
  393.                 entry = entry:sub(1, -2)
  394.             end
  395.         end
  396.     end
  397. end
  398.  
  399. local function cooldown(secs)
  400.     for left = secs, 1, -1 do
  401.         term.setBackgroundColor(colors.black)
  402.         term.clear()
  403.         center(8, "Too many attempts", colors.red)
  404.         center(10, "No modem to send a code.", colors.gray)
  405.         center(12, "Try again in " .. left .. "s", colors.white)
  406.         local t = os.startTimer(1)
  407.         repeat
  408.             local e, id = os.pullEventRaw()
  409.         until e == "timer" and id == t
  410.     end
  411. end
  412.  
  413. local function unlockedScreen()
  414.     while true do
  415.         term.setBackgroundColor(colors.black)
  416.         term.clear()
  417.         center(2, (os.getComputerLabel() or "Door") .. " - unlocked", colors.white)
  418.         local bw, bh = 25, 5
  419.         local bx = math.floor((w - bw) / 2) + 1
  420.         local by = math.floor((h - bh) / 2) + 1
  421.         fill(bx, by, bw, bh, colors.green)
  422.         term.setCursorPos(bx + math.floor((bw - 9) / 2), by + 2)
  423.         term.setTextColor(colors.white)
  424.         term.write("OPEN DOOR")
  425.         fill(2, h - 1, 6, 1, colors.gray)
  426.         term.setCursorPos(3, h - 1)
  427.         term.setTextColor(colors.white)
  428.         term.write("Lock")
  429.         fill(w - 8, h - 1, 7, 1, colors.gray)
  430.         term.setCursorPos(w - 7, h - 1)
  431.         term.write("Shell")
  432.  
  433.         local timer = AUTO_LOCK > 0 and os.startTimer(AUTO_LOCK) or nil
  434.         local e, a, b, c = os.pullEventRaw()
  435.         if e == "timer" and a == timer then
  436.             return "lock" -- idle too long
  437.         elseif e == "mouse_click" then
  438.             if b >= bx and b <= bx + bw - 1 and c >= by and c <= by + bh - 1 then
  439.                 fill(bx, by, bw, bh, colors.lime)
  440.                 term.setCursorPos(bx + math.floor((bw - 10) / 2), by + 2)
  441.                 term.setTextColor(colors.black)
  442.                 term.write("DOOR OPEN!")
  443.                 pcall(openDoor)
  444.             elseif c == h - 1 and b >= 2 and b <= 7 then
  445.                 return "lock"
  446.             elseif c == h - 1 and b >= w - 8 then
  447.                 return "shell"
  448.             end
  449.         end
  450.     end
  451. end
  452.  
  453. -- first-run setup
  454. if not st.pin then
  455.     local m
  456.     while true do
  457.         local a = keypad("Set up Door Lock", m, { hint = "Choose a PIN (3-8 digits)" })
  458.         local b = keypad("Set up Door Lock", nil, { hint = "Repeat the PIN" })
  459.         if a == b then
  460.             st.pin = hash(a)
  461.             st.attempts = 0
  462.             save()
  463.             break
  464.         end
  465.         m = "PINs didn't match - retry"
  466.     end
  467.     local f = fs.open("startup.lua", "w")
  468.     f.write("-- Door lock (created by doorlock)\n")
  469.     f.write("shell.run(\"doorlock.lua\")\n")
  470.     f.close()
  471. end
  472.  
  473. local msg
  474. while true do
  475.     if st.lockout then
  476.         local entered = keypad("LOCKED OUT", msg, {
  477.             hint = "Code sent to pocket #" .. OWNER_ID,
  478.             onResend = hasModem and function() sendCode(st.lockout) end or nil,
  479.         })
  480.         if entered == st.lockout then
  481.             st.lockout = nil
  482.             st.attempts = 0
  483.             save()
  484.             msg = nil
  485.             if unlockedScreen() == "shell" then break end
  486.         else
  487.             msg = "Wrong code"
  488.         end
  489.     else
  490.         local entered = keypad("\7 Locked", msg)
  491.         if hash(entered) == st.pin then
  492.             st.attempts = 0
  493.             save()
  494.             msg = nil
  495.             if unlockedScreen() == "shell" then break end
  496.         else
  497.             st.attempts = (st.attempts or 0) + 1
  498.             save()
  499.             if st.attempts >= 3 then
  500.                 if hasModem then
  501.                     st.lockout = tostring(math.random(100000, 999999))
  502.                     save()
  503.                     sendCode(st.lockout)
  504.                     msg = nil
  505.                 else
  506.                     cooldown(60)
  507.                     st.attempts = 0
  508.                     save()
  509.                     msg = nil
  510.                 end
  511.             else
  512.                 msg = "Wrong PIN - " .. (3 - st.attempts) .. " left"
  513.             end
  514.         end
  515.     end
  516. end
  517.  
  518. term.setBackgroundColor(colors.black)
  519. term.setTextColor(colors.white)
  520. term.clear()
  521. term.setCursorPos(1, 1)
  522. print("Door lock exited. Run 'reboot' to lock again.")
  523. ]==],
  524. ["bank.lua"] = [==[
  525. -- PocketOS bank server: stores everyone's money.
  526. --
  527. -- Setup: pick a computer with a wireless modem, set `bank = <its ID>`
  528. -- in /pocketos/config.lua (on every device), and reboot it - it boots
  529. -- into the bank automatically, just like the tracker hub. Accounts are
  530. -- keyed by pocket computer ID and saved in /.bank.
  531. --
  532. -- Deposits come from ATMs (see atm.lua); payments come from the Bank
  533. -- app on pockets. The bank messages people their receipts.
  534.  
  535. -------------------------------------------------- CONFIG - edit me --
  536.  
  537. -- Only these computer IDs may submit ATM deposits, e.g. { 12, 15 }.
  538. -- nil = accept deposits from any computer (fine among friends).
  539. local ATMS = nil
  540.  
  541. -- Balance for brand-new accounts.
  542. local START = 0
  543.  
  544. -----------------------------------------------------------------------
  545.  
  546. local DATA = "/.bank"
  547.  
  548. local accounts = {}
  549. if fs.exists(DATA) then
  550.     local f = fs.open(DATA, "r")
  551.     accounts = textutils.unserialize(f.readAll() or "") or {}
  552.     f.close()
  553. end
  554.  
  555. local function save()
  556.     local f = fs.open(DATA, "w")
  557.     f.write(textutils.serialize(accounts))
  558.     f.close()
  559. end
  560.  
  561. local MDATA = "/.market"
  562. local market = { nextId = 1, items = {} }
  563. if fs.exists(MDATA) then
  564.     local f = fs.open(MDATA, "r")
  565.     local v = textutils.unserialize(f.readAll() or "")
  566.     f.close()
  567.     if type(v) == "table" and type(v.items) == "table" then market = v end
  568. end
  569.  
  570. local function saveMarket()
  571.     local f = fs.open(MDATA, "w")
  572.     f.write(textutils.serialize(market))
  573.     f.close()
  574. end
  575.  
  576. local hasModem = false
  577. for _, side in ipairs(peripheral.getNames()) do
  578.     if peripheral.getType(side) == "modem" then
  579.         if pcall(rednet.open, side) then hasModem = true end
  580.     end
  581. end
  582. if not hasModem then
  583.     print("Attach a wireless modem first!")
  584.     return
  585. end
  586. rednet.host("pocketos.bank", "bank" .. os.getComputerID())
  587.  
  588. -- admin safety: only these IDs may read the transaction ledger
  589. local admins = { 11, 18 }
  590. do
  591.     local ok, cfg = pcall(dofile, "/pocketos/config.lua")
  592.     if ok and type(cfg) == "table" and type(cfg.admins) == "table" then
  593.         admins = cfg.admins
  594.     end
  595. end
  596. local function isAdmin(id)
  597.     for _, a in ipairs(admins) do
  598.         if tonumber(a) == tonumber(id) then return true end
  599.     end
  600.     return false
  601. end
  602.  
  603. local log = {}
  604. local function addLog(s)
  605.     local ok, t = pcall(os.date, "%H:%M ")
  606.     table.insert(log, 1, (ok and t or "") .. s)
  607.     while #log > 12 do table.remove(log) end
  608. end
  609.  
  610. -- persistent transaction history for the developer Ledger view
  611. local TDATA = "/.bank_txns"
  612. local txns = {}
  613. if fs.exists(TDATA) then
  614.     local f = fs.open(TDATA, "r")
  615.     txns = textutils.unserialize(f.readAll() or "") or {}
  616.     f.close()
  617. end
  618.  
  619. local function record(kind, from, to, amount)
  620.     local ok, t = pcall(os.date, "%H:%M")
  621.     table.insert(txns, 1, {
  622.         kind = kind, from = from, to = to, amount = amount,
  623.         time = ok and t or "",
  624.     })
  625.     while #txns > 200 do table.remove(txns) end
  626.     local f = fs.open(TDATA, "w")
  627.     f.write(textutils.serialize(txns))
  628.     f.close()
  629. end
  630.  
  631. local function get(id)
  632.     local key = tostring(id)
  633.     local acc = accounts[key]
  634.     if not acc then
  635.         acc = { bal = START }
  636.         accounts[key] = acc
  637.     end
  638.     return acc
  639. end
  640.  
  641. local function notify(id, text)
  642.     rednet.send(tonumber(id), { text = text, label = "Bank" }, "pocketos")
  643. end
  644.  
  645. local color = term.isColor and term.isColor()
  646. local function tint(c)
  647.     if color then term.setTextColor(c) end
  648. end
  649.  
  650. local function draw()
  651.     term.setBackgroundColor(colors.black)
  652.     term.setTextColor(colors.white)
  653.     term.clear()
  654.     local w, h = term.getSize()
  655.     term.setCursorPos(1, 1)
  656.     tint(colors.yellow)
  657.     write("PocketOS Bank - computer #" .. os.getComputerID())
  658.     local n, total = 0, 0
  659.     for _, acc in pairs(accounts) do
  660.         n = n + 1
  661.         total = total + (acc.bal or 0)
  662.     end
  663.     term.setCursorPos(1, 2)
  664.     tint(colors.lightGray)
  665.     write(n .. " accounts, $" .. total .. " in circulation, "
  666.         .. #market.items .. " listings")
  667.     term.setCursorPos(1, 4)
  668.     write("Recent:")
  669.     tint(colors.white)
  670.     for i, s in ipairs(log) do
  671.         if 4 + i > h - 1 then break end
  672.         term.setCursorPos(1, 4 + i)
  673.         write(s:sub(1, w))
  674.     end
  675.     term.setCursorPos(1, h)
  676.     tint(colors.gray)
  677.     write("Hold Ctrl+T to stop")
  678. end
  679.  
  680. local function allowedATM(id)
  681.     if not ATMS then return true end
  682.     for _, a in ipairs(ATMS) do
  683.         if a == id then return true end
  684.     end
  685.     return false
  686. end
  687.  
  688. addLog("Bank started")
  689. draw()
  690. while true do
  691.     local id, msg = rednet.receive("pocketos.bank")
  692.     if type(msg) == "table" and type(msg.type) == "string" then
  693.         local reply
  694.         if msg.type == "balance" then
  695.             local acc = get(id)
  696.             if type(msg.label) == "string" and #msg.label > 0 then
  697.                 acc.label = msg.label:sub(1, 12)
  698.                 save()
  699.             end
  700.             reply = { ok = true, balance = acc.bal, label = acc.label }
  701.         elseif msg.type == "info" then
  702.             local account = tonumber(msg.account)
  703.             if account then
  704.                 local acc = get(account)
  705.                 reply = { ok = true, balance = acc.bal, label = acc.label }
  706.             else
  707.                 reply = { ok = false, err = "Bad account" }
  708.             end
  709.         elseif msg.type == "deposit" then
  710.             local account = tonumber(msg.account)
  711.             local amount = tonumber(msg.amount)
  712.             amount = amount and math.floor(amount)
  713.             if not allowedATM(id) then
  714.                 reply = { ok = false, err = "ATM not authorised" }
  715.             elseif not account or not amount or amount <= 0
  716.                 or amount > 1000000000 then
  717.                 reply = { ok = false, err = "Bad deposit" }
  718.             else
  719.                 local acc = get(account)
  720.                 acc.bal = (acc.bal or 0) + amount
  721.                 save()
  722.                 addLog("#" .. account .. " deposited $" .. amount)
  723.                 record("deposit", nil, account, amount)
  724.                 notify(account, "Deposited $" .. amount
  725.                     .. ". Balance: $" .. acc.bal)
  726.                 reply = { ok = true, balance = acc.bal, label = acc.label }
  727.             end
  728.         elseif msg.type == "pay" then
  729.             local to = tonumber(msg.to)
  730.             local amount = tonumber(msg.amount)
  731.             amount = amount and math.floor(amount)
  732.             local from = get(id)
  733.             if type(msg.label) == "string" and #msg.label > 0 then
  734.                 from.label = msg.label:sub(1, 12)
  735.             end
  736.             if not to or not amount or amount <= 0 then
  737.                 reply = { ok = false, err = "Bad payment" }
  738.             elseif to == id then
  739.                 reply = { ok = false, err = "That's you!" }
  740.             elseif (from.bal or 0) < amount then
  741.                 reply = { ok = false, err = "Not enough money" }
  742.             else
  743.                 local dest = get(to)
  744.                 from.bal = from.bal - amount
  745.                 dest.bal = (dest.bal or 0) + amount
  746.                 save()
  747.                 addLog("#" .. id .. " paid #" .. to .. " $" .. amount)
  748.                 record("pay", id, to, amount)
  749.                 notify(to, "You got $" .. amount .. " from "
  750.                     .. (from.label and (from.label .. " ") or "")
  751.                     .. "#" .. id .. ". Balance: $" .. dest.bal)
  752.                 reply = { ok = true, balance = from.bal }
  753.             end
  754.         elseif msg.type == "txns" then
  755.             if not isAdmin(id) then
  756.                 reply = { ok = false, err = "Not an admin" }
  757.             else
  758.                 local n = math.min(#txns, tonumber(msg.limit) or 40)
  759.                 local out = {}
  760.                 for i = 1, n do out[i] = txns[i] end
  761.                 reply = { ok = true, txns = out }
  762.             end
  763.         elseif msg.type == "mklist" then
  764.             reply = { ok = true, items = market.items }
  765.         elseif msg.type == "mkpost" then
  766.             local name = type(msg.name) == "string" and msg.name:sub(1, 20) or ""
  767.             local price = tonumber(msg.price)
  768.             price = price and math.floor(price)
  769.             local desc = type(msg.desc) == "string" and msg.desc:sub(1, 60) or ""
  770.             local mine = 0
  771.             for _, it in ipairs(market.items) do
  772.                 if it.seller == id then mine = mine + 1 end
  773.             end
  774.             if name == "" or not price or price <= 0 or price > 1000000000 then
  775.                 reply = { ok = false, err = "Bad listing" }
  776.             elseif mine >= 8 then
  777.                 reply = { ok = false, err = "Max 8 listings" }
  778.             elseif #market.items >= 60 then
  779.                 reply = { ok = false, err = "Market is full" }
  780.             else
  781.                 local acc = get(id)
  782.                 if type(msg.label) == "string" and #msg.label > 0 then
  783.                     acc.label = msg.label:sub(1, 12)
  784.                     save()
  785.                 end
  786.                 market.items[#market.items + 1] = {
  787.                     id = market.nextId,
  788.                     seller = id,
  789.                     label = acc.label,
  790.                     name = name,
  791.                     price = price,
  792.                     desc = desc,
  793.                 }
  794.                 market.nextId = market.nextId + 1
  795.                 saveMarket()
  796.                 addLog("#" .. id .. " listed " .. name .. " $" .. price)
  797.                 reply = { ok = true, id = market.nextId - 1 }
  798.             end
  799.         elseif msg.type == "mkremove" then
  800.             local lid = tonumber(msg.id)
  801.             local removed = false
  802.             for k, it in ipairs(market.items) do
  803.                 if it.id == lid and it.seller == id then
  804.                     table.remove(market.items, k)
  805.                     removed = true
  806.                     break
  807.                 end
  808.             end
  809.             if removed then
  810.                 saveMarket()
  811.                 addLog("#" .. id .. " removed a listing")
  812.                 reply = { ok = true }
  813.             else
  814.                 reply = { ok = false, err = "Not your listing" }
  815.             end
  816.         end
  817.         if reply then
  818.             rednet.send(id, reply, "pocketos.bank")
  819.             draw()
  820.         end
  821.     end
  822. end
  823. ]==],
  824. ["atm.lua"] = [==[
  825. -- PocketOS ATM - touch-screen deposits into the PocketOS bank.
  826. --
  827. -- Hardware: an ADVANCED computer with a wireless modem, a Create
  828. -- display target ("create_target") whose display lists the deposit
  829. -- chest's contents, and redstone (or an Advanced Peripherals redstone
  830. -- integrator) that moves the deposited items away when pulsed.
  831. --
  832. -- Flow: player taps START, types their pocket computer ID, puts iron/
  833. -- gold/diamonds in the chest, taps DEPOSIT ITEMS, confirms - the items
  834. -- get taken and the value lands in their bank account (they also get a
  835. -- receipt in Messages). Runs once and takes over startup as a kiosk.
  836.  
  837. -------------------------------------------------- CONFIG - edit me --
  838.  
  839. local RATES = { iron = 1, gold = 5, diamond = 25 } -- $ per item
  840. local ACCEPT_SIDE = "bottom" -- redstone side that accepts the items
  841. local IDLE_TIME = 45         -- seconds before returning to the start
  842.  
  843. -- Runs when a deposit is accepted: move the items out of the chest.
  844. local redint = peripheral.find("redstoneIntegrator")
  845. local function acceptItems()
  846.     local out = redint or redstone
  847.     out.setOutput(ACCEPT_SIDE, true)
  848.     sleep(1)
  849.     out.setOutput(ACCEPT_SIDE, false)
  850. end
  851.  
  852. -----------------------------------------------------------------------
  853.  
  854. local NICE = { iron = "Iron", gold = "Gold", diamond = "Diamond" }
  855.  
  856. if not term.isColor or not term.isColor() then
  857.     print("The ATM needs an advanced (gold) computer.")
  858.     return
  859. end
  860.  
  861. local target = peripheral.find("create_target")
  862. local hasModem = false
  863. for _, side in ipairs(peripheral.getNames()) do
  864.     if peripheral.getType(side) == "modem" then
  865.         if pcall(rednet.open, side) then hasModem = true end
  866.     end
  867. end
  868.  
  869. local w, h = term.getSize()
  870.  
  871. -- take over startup so the ATM boots as a kiosk
  872. do
  873.     local cur = ""
  874.     if fs.exists("startup.lua") then
  875.         local f = fs.open("startup.lua", "r")
  876.         cur = f.readAll() or ""
  877.         f.close()
  878.     end
  879.     if not cur:find("atm.lua", 1, true) then
  880.         local f = fs.open("startup.lua", "w")
  881.         f.write("-- ATM kiosk (created by atm.lua)\n")
  882.         f.write("shell.run(\"atm.lua\")\n")
  883.         f.close()
  884.     end
  885. end
  886.  
  887. local function fill(x, y, wd, ht, bg)
  888.     term.setBackgroundColor(bg)
  889.     for i = 0, ht - 1 do
  890.         term.setCursorPos(x, y + i)
  891.         term.write(string.rep(" ", wd))
  892.     end
  893. end
  894.  
  895. local function center(y, s, fg)
  896.     term.setCursorPos(math.floor((w - #s) / 2) + 1, y)
  897.     term.setTextColor(fg)
  898.     term.setBackgroundColor(colors.black)
  899.     term.write(s)
  900. end
  901.  
  902. local function button(x, y, label, bg)
  903.     fill(x, y, #label + 2, 1, bg)
  904.     term.setCursorPos(x + 1, y)
  905.     term.setTextColor(colors.white)
  906.     term.write(label)
  907.     return { x1 = x, y1 = y, x2 = x + #label + 1, y2 = y }
  908. end
  909.  
  910. local function hit(r, x, y)
  911.     return r and x >= r.x1 and x <= r.x2 and y >= r.y1 and y <= r.y2
  912. end
  913.  
  914. local function screenMsg(title, lines, buttons)
  915.     term.setBackgroundColor(colors.black)
  916.     term.clear()
  917.     center(4, title, colors.white)
  918.     for i, s in ipairs(lines) do
  919.         center(6 + i, s, colors.lightGray)
  920.     end
  921.     local regions = {}
  922.     local total = #buttons - 1
  923.     for _, b in ipairs(buttons) do total = total + #b[1] + 2 end
  924.     local bx = math.floor((w - total) / 2) + 1
  925.     for i, b in ipairs(buttons) do
  926.         regions[i] = button(bx, 9 + #lines, b[1], b[2])
  927.         bx = bx + #b[1] + 3
  928.     end
  929.     while true do
  930.         local _, _, mx, my = os.pullEvent("mouse_click")
  931.         for i, r in ipairs(regions) do
  932.             if hit(r, mx, my) then return i end
  933.         end
  934.     end
  935. end
  936.  
  937. ------------------------------------------------------------- bank --
  938.  
  939. local bankId
  940. local function findBank()
  941.     if bankId then return bankId end
  942.     local ok, cfg = pcall(dofile, "/pocketos/config.lua")
  943.     if ok and type(cfg) == "table" and tonumber(cfg.bank) then
  944.         bankId = tonumber(cfg.bank)
  945.         return bankId
  946.     end
  947.     bankId = rednet.lookup("pocketos.bank")
  948.     return bankId
  949. end
  950.  
  951. local function request(msg)
  952.     if not hasModem then return nil, "No modem on the ATM" end
  953.     local bid = findBank()
  954.     if not bid then return nil, "No bank found" end
  955.     rednet.send(bid, msg, "pocketos.bank")
  956.     local id, resp = rednet.receive("pocketos.bank", 3)
  957.     if id == bid and type(resp) == "table" then return resp end
  958.     return nil, "Bank not answering"
  959. end
  960.  
  961. -- read the Create display target listing the deposit chest
  962. local function scanDeposit()
  963.     local found, total = {}, 0
  964.     if not target then return found, 0 end
  965.     for i = 1, 8 do
  966.         local ok, line = pcall(target.getLine, i)
  967.         if ok and type(line) == "string" and line ~= "" then
  968.             local amount = tonumber(line:match("(%d+)"))
  969.             if amount then
  970.                 local low = line:lower()
  971.                 local kind
  972.                 if low:find("iron", 1, true) then
  973.                     kind = "iron"
  974.                 elseif low:find("gold", 1, true) then
  975.                     kind = "gold"
  976.                 elseif low:find("diamond", 1, true) then
  977.                     kind = "diamond"
  978.                 end
  979.                 if kind and RATES[kind] then
  980.                     local value = amount * RATES[kind]
  981.                     found[#found + 1] = { n = amount, kind = kind, value = value }
  982.                     total = total + value
  983.                 end
  984.             end
  985.         end
  986.     end
  987.     return found, total
  988. end
  989.  
  990. --------------------------------------------------------------- ui --
  991.  
  992. local function keypad(title)
  993.     local entry = ""
  994.     local layout = {
  995.         { "1", "2", "3" },
  996.         { "4", "5", "6" },
  997.         { "7", "8", "9" },
  998.         { "C", "0", "OK" },
  999.     }
  1000.     while true do
  1001.         term.setBackgroundColor(colors.black)
  1002.         term.clear()
  1003.         center(2, title, colors.white)
  1004.         center(4, entry == "" and "_" or entry, colors.yellow)
  1005.         local regions = {}
  1006.         local x0 = math.floor(w / 2) - 8
  1007.         for r = 1, 4 do
  1008.             for cn = 1, 3 do
  1009.                 local label = layout[r][cn]
  1010.                 local bx = x0 + (cn - 1) * 6
  1011.                 local by = 4 + r * 2
  1012.                 fill(bx, by, 5, 1, label == "OK" and colors.green or colors.gray)
  1013.                 term.setCursorPos(bx + math.floor((5 - #label) / 2), by)
  1014.                 term.setTextColor(colors.white)
  1015.                 term.write(label)
  1016.                 regions[#regions + 1] = { x1 = bx, x2 = bx + 4, y = by, label = label }
  1017.             end
  1018.         end
  1019.         center(14, "[ Cancel ]", colors.red)
  1020.         center(h, "Your ID is shown in Settings on your pocket", colors.gray)
  1021.  
  1022.         local e, a, b, c = os.pullEvent()
  1023.         if e == "mouse_click" then
  1024.             if c == 14 then return nil end
  1025.             for _, r in ipairs(regions) do
  1026.                 if c == r.y and b >= r.x1 and b <= r.x2 then
  1027.                     if r.label == "OK" then
  1028.                         if #entry > 0 then return tonumber(entry) end
  1029.                     elseif r.label == "C" then
  1030.                         entry = ""
  1031.                     elseif #entry < 7 then
  1032.                         entry = entry .. r.label
  1033.                     end
  1034.                 end
  1035.             end
  1036.         elseif e == "char" and a:match("^%d$") and #entry < 7 then
  1037.             entry = entry .. a
  1038.         elseif e == "key" then
  1039.             if a == keys.enter and #entry > 0 then return tonumber(entry) end
  1040.             if a == keys.backspace then entry = entry:sub(1, -2) end
  1041.         end
  1042.     end
  1043. end
  1044.  
  1045. local function idleScreen()
  1046.     term.setBackgroundColor(colors.black)
  1047.     term.clear()
  1048.     center(3, "PocketOS ATM", colors.yellow)
  1049.     center(5, string.format("Iron $%d   Gold $%d   Diamond $%d",
  1050.         RATES.iron, RATES.gold, RATES.diamond), colors.lightGray)
  1051.     local bw, bh = 15, 3
  1052.     local bx = math.floor((w - bw) / 2) + 1
  1053.     fill(bx, 8, bw, bh, colors.green)
  1054.     term.setCursorPos(bx + math.floor((bw - 5) / 2), 9)
  1055.     term.setTextColor(colors.white)
  1056.     term.write("START")
  1057.     if not target then
  1058.         center(13, "! No Create target attached", colors.red)
  1059.     end
  1060.     if not hasModem then
  1061.         center(14, "! No wireless modem", colors.red)
  1062.     end
  1063.     center(h, "ATM #" .. os.getComputerID(), colors.gray)
  1064.     while true do
  1065.         local e = os.pullEvent("mouse_click")
  1066.         if e then return end
  1067.     end
  1068. end
  1069.  
  1070. local function depositFlow(account)
  1071.     local items, total = scanDeposit()
  1072.     if not target then
  1073.         screenMsg("ATM Error", { "No Create display", "target attached." },
  1074.             { { "OK", colors.blue } })
  1075.         return nil
  1076.     end
  1077.     if total <= 0 then
  1078.         screenMsg("Nothing to Deposit", {
  1079.             "Put iron, gold or",
  1080.             "diamonds into the",
  1081.             "deposit chest first.",
  1082.         }, { { "OK", colors.blue } })
  1083.         return nil
  1084.     end
  1085.  
  1086.     term.setBackgroundColor(colors.black)
  1087.     term.clear()
  1088.     center(2, "Deposit for #" .. account, colors.white)
  1089.     local y = 4
  1090.     for _, it in ipairs(items) do
  1091.         center(y, it.n .. " " .. NICE[it.kind] .. " = $" .. it.value, colors.lightGray)
  1092.         y = y + 1
  1093.     end
  1094.     center(y + 1, "Total: $" .. total, colors.yellow)
  1095.     local accept = button(math.floor(w / 2) - 11, y + 3, "Accept", colors.green)
  1096.     local cancel = button(math.floor(w / 2) + 3, y + 3, "Cancel", colors.red)
  1097.     while true do
  1098.         local _, _, mx, my = os.pullEvent("mouse_click")
  1099.         if hit(accept, mx, my) then break end
  1100.         if hit(cancel, mx, my) then return nil end
  1101.     end
  1102.  
  1103.     center(y + 5, "Processing...", colors.gray)
  1104.     pcall(acceptItems)
  1105.     local resp, err
  1106.     for _ = 1, 3 do
  1107.         resp, err = request({ type = "deposit", account = account, amount = total })
  1108.         if resp then break end
  1109.         sleep(1)
  1110.     end
  1111.     if resp and resp.ok then
  1112.         screenMsg("Deposited!", {
  1113.             "$" .. total .. " added.",
  1114.             "New balance: $" .. (resp.balance or "?"),
  1115.         }, { { "OK", colors.green } })
  1116.         return resp.balance
  1117.     end
  1118.     screenMsg("Bank Offline!", {
  1119.         "Items were taken but",
  1120.         "not credited. Tell an",
  1121.         "admin: #" .. account .. " +$" .. total,
  1122.     }, { { "OK", colors.red } })
  1123.     return nil
  1124. end
  1125.  
  1126. local function accountScreen(account, info)
  1127.     local balance = info.balance or 0
  1128.     local label = info.label
  1129.     while true do
  1130.         term.setBackgroundColor(colors.black)
  1131.         term.clear()
  1132.         center(2, "#" .. account .. (label and (" " .. label) or ""), colors.white)
  1133.         center(4, "Balance: $" .. balance, colors.yellow)
  1134.         local bw, bh = 25, 5
  1135.         local bx = math.floor((w - bw) / 2) + 1
  1136.         local by = 7
  1137.         fill(bx, by, bw, bh, colors.green)
  1138.         term.setCursorPos(bx + math.floor((bw - 13) / 2), by + 2)
  1139.         term.setTextColor(colors.white)
  1140.         term.write("DEPOSIT ITEMS")
  1141.         fill(w - 7, h - 1, 6, 1, colors.gray)
  1142.         term.setCursorPos(w - 6, h - 1)
  1143.         term.setTextColor(colors.white)
  1144.         term.write("Done")
  1145.         center(h, "Insert items into the chest first", colors.gray)
  1146.  
  1147.         local t = os.startTimer(IDLE_TIME)
  1148.         local e, a, b, c = os.pullEvent()
  1149.         if e == "timer" and a == t then
  1150.             return
  1151.         elseif e == "mouse_click" then
  1152.             if b >= bx and b <= bx + bw - 1 and c >= by and c <= by + bh - 1 then
  1153.                 local nb = depositFlow(account)
  1154.                 if nb then balance = nb end
  1155.             elseif c == h - 1 and b >= w - 7 then
  1156.                 return
  1157.             end
  1158.         end
  1159.     end
  1160. end
  1161.  
  1162. --------------------------------------------------------------- run --
  1163.  
  1164. while true do
  1165.     idleScreen()
  1166.     local account = keypad("Enter your pocket ID")
  1167.     if account then
  1168.         term.setBackgroundColor(colors.black)
  1169.         term.clear()
  1170.         center(9, "Checking...", colors.gray)
  1171.         local info, err = request({ type = "info", account = account })
  1172.         if info and info.ok then
  1173.             accountScreen(account, info)
  1174.         else
  1175.             screenMsg("No Bank", { tostring((info and info.err) or err or "Offline") },
  1176.                 { { "OK", colors.red } })
  1177.         end
  1178.     end
  1179. end
  1180. ]==],
  1181. ["update.lua"] = [==[
  1182. -- PocketOS updater (command line).
  1183. -- For the bank and tracker computers, which boot into their role
  1184. -- program instead of PocketOS and so have no Settings > Software
  1185. -- Update screen. Hold Ctrl+T to reach the shell, then run:
  1186. --     update <pastebin-code-or-url>
  1187. -- With no argument it uses the 'update' link from config.lua.
  1188. -- Reinstalls PocketOS in place; config.lua (bank/tracker IDs) is kept.
  1189.  
  1190. local args = { ... }
  1191. local src = args[1]
  1192.  
  1193. if not src then
  1194.     local ok, cfg = pcall(dofile, "/pocketos/config.lua")
  1195.     if ok and type(cfg) == "table" and type(cfg.update) == "string" then
  1196.         src = cfg.update
  1197.     end
  1198. end
  1199. if not src then
  1200.     write("Installer pastebin code or URL: ")
  1201.     src = read()
  1202. end
  1203. src = (src or ""):gsub("%s", "")
  1204. if src == "" then
  1205.     print("Nothing to fetch.")
  1206.     return
  1207. end
  1208. if not http then
  1209.     print("HTTP is disabled in the server config.")
  1210.     return
  1211. end
  1212.  
  1213. local url = src:match("^https?://") and src or ("https://pastebin.com/raw/" .. src)
  1214. print("Downloading installer...")
  1215. local res = http.get(url)
  1216. if not res then
  1217.     print("Download failed - check the code/URL.")
  1218.     return
  1219. end
  1220. local body = res.readAll() or ""
  1221. res.close()
  1222. if not body:find("PocketOS installer", 1, true) then
  1223.     print("That doesn't look like a PocketOS installer.")
  1224.     return
  1225. end
  1226.  
  1227. local fn, err = load(body, "installer", "t", _ENV)
  1228. if not fn then
  1229.     print("Installer is broken: " .. tostring(err))
  1230.     return
  1231. end
  1232. print("Installing (config is kept)...")
  1233. fn() -- writes files and reboots
  1234. ]==],
  1235. ["pocketos/config.lua"] = [==[
  1236. -- PocketOS configuration.
  1237. -- Safe to edit (run: edit /pocketos/config.lua). Installs and updates
  1238. -- never overwrite this file once it exists.
  1239.  
  1240. return {
  1241.     -- Location tracking: set this to a computer ID (a number, e.g. 5)
  1242.     -- and this device will send its GPS position to that computer every
  1243.     -- few seconds. The computer with that ID automatically boots as the
  1244.     -- tracker hub instead of PocketOS. Set to nil to disable.
  1245.     -- Needs wireless modems and a GPS host cluster in range.
  1246.     tracker = 7,
  1247.  
  1248.     -- Seconds between location reports.
  1249.     trackEvery = 15,
  1250.  
  1251.     -- Where Software Update looks for new versions: a pastebin code in
  1252.     -- quotes (e.g. "AbC123Xy") or a full URL to an installer. Settings >
  1253.     -- Software Update comes prefilled with this, so updating is one tap.
  1254.     update = "kwgdK8Ps",
  1255.  
  1256.     -- Which Groq model the AI app talks to.
  1257.     aiModel = "llama-3.3-70b-versatile",
  1258.  
  1259.     -- Computer ID of the bank server (see bank.lua). That computer
  1260.     -- auto-boots as the bank; pockets and ATMs will contact it
  1261.     -- directly. nil = find the bank automatically by rednet lookup.
  1262.     bank = 20,
  1263.  
  1264.     -- ADMIN SAFETY. Only these computer IDs may use developer powers -
  1265.     -- remote shell, ban/unban, and the Fleet/Ledger views. Every other
  1266.     -- device is ignored even if it somehow sends those commands. Keep
  1267.     -- this list short and trusted.
  1268.     admins = { 11, 18 },
  1269.  
  1270.     -- Online music: URL of the PocketOS music server (see musicserver.py).
  1271.     -- e.g. "http://mybox.example.com:8080". When set, the Music app shows
  1272.     -- a "Search online music" option that streams full-quality audio.
  1273.     -- nil = disabled. Note: CC: Tweaked blocks private/LAN addresses by
  1274.     -- default, so host it publicly or allow the address in the CC config.
  1275.     musicServer = "https://pocketos-web-1.onrender.com",
  1276. }
  1277. ]==],
  1278. ["pocketos/main.lua"] = [==[
  1279. -- PocketOS ------------------------------------------------------------
  1280. -- A tiny Apple-flavoured OS for CC: Tweaked advanced pocket computers.
  1281. -- Booted by /startup.lua. Apps live in /pocketos/apps.
  1282.  
  1283. local ROOT = "/pocketos"
  1284. local ui = dofile(ROOT .. "/lib/ui.lua")
  1285.  
  1286. math.randomseed((os.epoch and os.epoch("utc") or os.time() * 1000) + os.getComputerID())
  1287.  
  1288. ---------------------------------------------------------- shared state --
  1289.  
  1290. local ctx = {
  1291.     version = "3.4",
  1292.     protocol = "pocketos",
  1293.     dataDir = "/.pocketos",
  1294.     messages = {},
  1295.     modem = false,
  1296. }
  1297.  
  1298. function ctx.load(name, default)
  1299.     local path = ctx.dataDir .. "/" .. name
  1300.     if fs.exists(path) then
  1301.         local f = fs.open(path, "r")
  1302.         local v = textutils.unserialize(f.readAll() or "")
  1303.         f.close()
  1304.         if v ~= nil then return v end
  1305.     end
  1306.     return default
  1307. end
  1308.  
  1309. function ctx.save(name, value)
  1310.     if not fs.exists(ctx.dataDir) then fs.makeDir(ctx.dataDir) end
  1311.     local f = fs.open(ctx.dataDir .. "/" .. name, "w")
  1312.     f.write(textutils.serialize(value))
  1313.     f.close()
  1314. end
  1315.  
  1316. function ctx.addMessage(m)
  1317.     table.insert(ctx.messages, m)
  1318.     while #ctx.messages > 100 do table.remove(ctx.messages, 1) end
  1319.     ctx.save("messages", ctx.messages)
  1320. end
  1321.  
  1322. function ctx.unreadCount()
  1323.     local n = 0
  1324.     for _, m in ipairs(ctx.messages) do
  1325.         if m.dir == "in" and not m.read then n = n + 1 end
  1326.     end
  1327.     return n
  1328. end
  1329.  
  1330. -- Save songs from a file_transfer event (files drag-and-dropped onto the
  1331. -- Minecraft window, CC: Tweaked 1.101+). Returns how many were saved.
  1332. function ctx.importFiles(transfer)
  1333.     local saved, skipped = 0, 0
  1334.     if not transfer or not transfer.getFiles then return 0, 0 end
  1335.     if not fs.exists("/music") then fs.makeDir("/music") end
  1336.     for _, file in ipairs(transfer.getFiles()) do
  1337.         local name = (file.getName() or "song"):gsub("[^%w%._%- ]", "")
  1338.         local lower = name:lower()
  1339.         if lower:match("%.nbs$") or lower:match("%.mid$") or lower:match("%.midi$") then
  1340.             local ext = name:match("(%.[^%.]+)$")
  1341.             local path = "/music/" .. name
  1342.             local n = 1
  1343.             while fs.exists(path) do
  1344.                 n = n + 1
  1345.                 path = "/music/" .. name:gsub("%.[^%.]+$", "") .. n .. ext
  1346.             end
  1347.             local data = file.readAll() or ""
  1348.             local f = fs.open(path, "wb")
  1349.             f.write(data)
  1350.             f.close()
  1351.             saved = saved + 1
  1352.         else
  1353.             skipped = skipped + 1
  1354.         end
  1355.         pcall(file.close)
  1356.     end
  1357.     return saved, skipped
  1358. end
  1359.  
  1360. ctx.messages = ctx.load("messages", {})
  1361.  
  1362. do -- user-editable config; survives OS updates
  1363.     local ok, cfg = pcall(dofile, ROOT .. "/config.lua")
  1364.     ctx.config = (ok and type(cfg) == "table") and cfg or {}
  1365. end
  1366.  
  1367. -- shell-provided require, for rom modules like cc.audio.dfpwm
  1368. ctx.require = require
  1369. ctx.shell = shell
  1370.  
  1371. -- developer mode (unlocked by tapping Computer ID in Settings)
  1372. ctx.dev = ctx.load("devmode", false) and true or false
  1373.  
  1374. -- ban state, pushed by a developer; { reason, by, at } or nil
  1375. ctx.banned = ctx.load("ban", nil)
  1376. if type(ctx.banned) ~= "table" then ctx.banned = nil end
  1377.  
  1378. -- clock preferences (set in Settings > Date & Time)
  1379. ui.tzOffset = tonumber(ctx.load("tz_offset", 0)) or 0
  1380. ui.time24 = ctx.load("time_24h", true) ~= false
  1381.  
  1382. -- Admin safety: only these IDs may use developer powers. Defaults to a
  1383. -- fixed pair if config.lua doesn't list any (keeps old installs safe).
  1384. ctx.admins = (ctx.config and type(ctx.config.admins) == "table")
  1385.     and ctx.config.admins or { 11, 18 }
  1386. function ctx.isAdmin(id)
  1387.     for _, a in ipairs(ctx.admins) do
  1388.         if tonumber(a) == tonumber(id) then return true end
  1389.     end
  1390.     return false
  1391. end
  1392.  
  1393. for _, side in ipairs(peripheral.getNames()) do
  1394.     if peripheral.getType(side) == "modem" then
  1395.         if pcall(rednet.open, side) then ctx.modem = true end
  1396.     end
  1397. end
  1398.  
  1399. ---------------------------------------------------------- home screen --
  1400.  
  1401. local apps = {
  1402.     { id = "messages", name = "Messages", color = colors.green,   glyph = "\7\7\7" },
  1403.     { folder = true,   name = "Games" },
  1404.     { id = "music",    name = "Music",    color = colors.red,     glyph = "\14" },
  1405.     { id = "web",      name = "Web",      color = colors.blue,    glyph = "G" },
  1406.     { id = "ai",       name = "AI",       color = colors.magenta, glyph = "\1" },
  1407.     { id = "bank",     name = "Bank",     color = colors.black,   glyph = "$" },
  1408.     { id = "market",   name = "Market",   color = colors.purple,  glyph = "%" },
  1409.     { id = "maps",     name = "Maps",     color = colors.lime,    glyph = "\24" },
  1410.     { id = "gps",      name = "GPS",      color = colors.orange,  glyph = "\4" },
  1411.     { id = "settings", name = "Settings", color = colors.gray,    glyph = "\15" },
  1412. }
  1413.  
  1414. local devApp = { id = "dev", name = "Dev", color = colors.cyan, glyph = "\187" }
  1415.  
  1416. local function homeApps()
  1417.     -- Dev app appears only on admin devices with developer mode on.
  1418.     if not (ctx.dev and ctx.isAdmin(os.getComputerID())) then return apps end
  1419.     local list = {}
  1420.     for _, a in ipairs(apps) do list[#list + 1] = a end
  1421.     table.insert(list, #list, devApp) -- just before Settings
  1422.     return list
  1423. end
  1424.  
  1425. local games = {
  1426.     { id = "snake",     name = "Snake",  color = colors.lime,      glyph = "~ \7", dir = "games" },
  1427.     { id = "blocks",    name = "Blocks", color = colors.purple,    glyph = "##",   dir = "games" },
  1428.     { id = "match",     name = "Match",  color = colors.pink,      glyph = "\3",   dir = "games" },
  1429.     { id = "mines",     name = "Mines",  color = colors.black,     glyph = "*",    dir = "games" },
  1430.     { id = "tetris",    name = "Tetris", color = colors.cyan,      glyph = "T",    dir = "games" },
  1431.     { id = "solitaire", name = "Cards",  color = colors.green,     glyph = "\6\3", dir = "games" },
  1432.     { id = "flappy",    name = "Flappy", color = colors.yellow,    glyph = "\16",  dir = "games" },
  1433.     { id = "sudoku",    name = "Sudoku", color = colors.lightBlue, glyph = "9",    dir = "games" },
  1434. }
  1435.  
  1436. local function drawTile(app, x, y, iw, ih)
  1437.     local bg = app.folder and colors.lightGray or app.color
  1438.     ui.fill(x, y, iw, ih, bg)
  1439.     -- knock out the four corners so the icon reads as rounded
  1440.     ui.text(x, y, " ", colors.white, colors.white)
  1441.     ui.text(x + iw - 1, y, " ", colors.white, colors.white)
  1442.     ui.text(x, y + ih - 1, " ", colors.white, colors.white)
  1443.     ui.text(x + iw - 1, y + ih - 1, " ", colors.white, colors.white)
  1444.     if app.folder then
  1445.         -- mini game icons inside the folder
  1446.         ui.fill(x + 2, y + 1, 1, 1, colors.lime)
  1447.         ui.fill(x + 4, y + 1, 1, 1, colors.cyan)
  1448.         ui.fill(x + 2, y + 2, 1, 1, colors.pink)
  1449.         ui.fill(x + 4, y + 2, 1, 1, colors.orange)
  1450.     else
  1451.         ui.text(x + math.floor((iw - #app.glyph) / 2), y + 1, app.glyph,
  1452.             colors.white, bg)
  1453.     end
  1454.     ui.text(x + math.floor((iw - #app.name) / 2), y + ih, app.name,
  1455.         colors.gray, colors.white)
  1456. end
  1457.  
  1458. local function drawGrid(list, regions, rowPitch)
  1459.     local iw, ih = 7, 3
  1460.     local gap = math.floor((ui.w - 3 * iw) / 4)
  1461.     local unread = ctx.unreadCount()
  1462.     for i, app in ipairs(list) do
  1463.         local col = (i - 1) % 3
  1464.         local row = math.floor((i - 1) / 3)
  1465.         local x = gap + col * (iw + gap) + 1
  1466.         local y = 3 + row * rowPitch
  1467.         drawTile(app, x, y, iw, ih)
  1468.         if app.id == "messages" and unread > 0 then
  1469.             ui.text(x + iw - 1, y, unread > 9 and "+" or tostring(unread),
  1470.                 colors.white, colors.red)
  1471.         end
  1472.         regions[#regions + 1] = { x1 = x, y1 = y, x2 = x + iw - 1, y2 = y + ih, app = app }
  1473.     end
  1474. end
  1475.  
  1476. local regions = {}
  1477.  
  1478. local function drawHome()
  1479.     ui.clear(colors.white)
  1480.     local name = os.getComputerLabel() or ("Pocket #" .. os.getComputerID())
  1481.     ui.text(2, 1, name:sub(1, 12), colors.gray, colors.white)
  1482.     local clk = ui.clock()
  1483.     -- signal dot (green with modem, red without) then the clock
  1484.     ui.text(ui.w - #clk - 2, 1, "\7", ctx.modem and colors.green or colors.red, colors.white)
  1485.     ui.text(ui.w - #clk, 1, clk, colors.black, colors.white)
  1486.     regions = {}
  1487.     drawGrid(homeApps(), regions, 4)
  1488.     -- iPhone-style home indicator
  1489.     ui.fill(math.floor(ui.w / 2) - 3, ui.h - 1, 8, 1, colors.lightGray)
  1490. end
  1491.  
  1492. local function runApp(app)
  1493.     local ok, err = pcall(function()
  1494.         local sub = app.dir and (app.dir .. "/") or ""
  1495.         local mod = dofile(ROOT .. "/apps/" .. sub .. app.id .. ".lua")
  1496.         mod.run(ui, ctx)
  1497.     end)
  1498.     if not ok then
  1499.         local msg = tostring(err)
  1500.         if msg:match("Terminated") then error(err, 0) end
  1501.         ui.clear(colors.white)
  1502.         ui.center(3, app.name .. " crashed", colors.red, colors.white)
  1503.         local y = 5
  1504.         for _, line in ipairs(ui.wrap(msg, ui.w - 4)) do
  1505.             if y > ui.h - 3 then break end
  1506.             ui.text(3, y, line, colors.gray, colors.white)
  1507.             y = y + 1
  1508.         end
  1509.         ui.center(ui.h - 1, "Tap to go home", colors.blue, colors.white)
  1510.         os.pullEvent("mouse_click")
  1511.     end
  1512. end
  1513.  
  1514. local function gamesScreen()
  1515.     while true do
  1516.         ui.clear(colors.white)
  1517.         ui.topBar("Games")
  1518.         local gregions = {}
  1519.         drawGrid(games, gregions, 4)
  1520.         ui.fill(math.floor(ui.w / 2) - 3, ui.h - 1, 8, 1, colors.lightGray)
  1521.         local e, a, b, c = os.pullEvent()
  1522.         if e == "mouse_click" then
  1523.             if ui.isBack(b, c) then return end
  1524.             for _, r in ipairs(gregions) do
  1525.                 if b >= r.x1 and b <= r.x2 and c >= r.y1 and c <= r.y2 then
  1526.                     runApp(r.app)
  1527.                     break
  1528.                 end
  1529.             end
  1530.         elseif e == "term_resize" then
  1531.             ui.w, ui.h = term.getSize()
  1532.         end
  1533.     end
  1534. end
  1535.  
  1536. -- Full-screen lock shown while this device is banned. Blocks until an
  1537. -- unban arrives (queued as "pocketos_unban"); swallows other input,
  1538. -- including Ctrl+T (the boot loop re-locks if a coroutine is killed).
  1539. local function banScreen()
  1540.     while ctx.banned do
  1541.         ui.fill(1, 1, ui.w, ui.h, colors.red)
  1542.         ui.center(3, "\4 BANNED \4", colors.white, colors.red)
  1543.         ui.center(5, "This device is locked.", colors.white, colors.red)
  1544.         ui.center(7, "Reason:", colors.white, colors.red)
  1545.         local y = 8
  1546.         for _, l in ipairs(ui.wrap(ctx.banned.reason or "No reason given", ui.w - 2)) do
  1547.             if y > ui.h - 4 then break end
  1548.             ui.center(y, l, colors.white, colors.red)
  1549.             y = y + 1
  1550.         end
  1551.         if ctx.banned.by then
  1552.             ui.center(ui.h - 2, "Banned by #" .. tostring(ctx.banned.by), colors.white, colors.red)
  1553.         end
  1554.         ui.center(ui.h, "Contact an admin", colors.white, colors.red)
  1555.         local e = os.pullEventRaw()
  1556.         if e == "pocketos_unban" then return end
  1557.         if e == "term_resize" then ui.w, ui.h = term.getSize() end
  1558.     end
  1559. end
  1560.  
  1561. local function home()
  1562.     while true do
  1563.         if ctx.banned then banScreen() end
  1564.         drawHome()
  1565.         local t = os.startTimer(15) -- keep the clock fresh
  1566.         while true do
  1567.             local e, a, b, c = os.pullEvent()
  1568.             if e == "pocketos_ban" then
  1569.                 break -- outer loop shows the ban screen
  1570.             elseif e == "mouse_click" then
  1571.                 local tapped
  1572.                 for _, r in ipairs(regions) do
  1573.                     if b >= r.x1 and b <= r.x2 and c >= r.y1 and c <= r.y2 then
  1574.                         tapped = r.app
  1575.                     end
  1576.                 end
  1577.                 if tapped then
  1578.                     if tapped.folder then
  1579.                         gamesScreen()
  1580.                     else
  1581.                         runApp(tapped)
  1582.                     end
  1583.                     break
  1584.                 end
  1585.             elseif e == "timer" and a == t then
  1586.                 break
  1587.             elseif e == "file_transfer" then
  1588.                 local saved = ctx.importFiles(a)
  1589.                 local msg = saved > 0
  1590.                     and ("Imported " .. saved .. " song" .. (saved == 1 and "" or "s"))
  1591.                     or "Only .nbs/.mid works"
  1592.                 ui.text(math.floor((ui.w - #msg - 2) / 2) + 1, ui.h - 3,
  1593.                     " " .. msg .. " ", colors.white,
  1594.                     saved > 0 and colors.green or colors.gray)
  1595.                 sleep(1.2)
  1596.                 break
  1597.             elseif e == "pocketos_message" or e == "term_resize" then
  1598.                 if e == "term_resize" then ui.w, ui.h = term.getSize() end
  1599.                 break
  1600.             end
  1601.         end
  1602.     end
  1603. end
  1604.  
  1605. ------------------------------------------------------ message listener --
  1606.  
  1607. local function listener()
  1608.     while true do
  1609.         local id, msg = rednet.receive(ctx.protocol)
  1610.         if type(msg) == "table" and type(msg.text) == "string" then
  1611.             ctx.addMessage({
  1612.                 peer = msg.bc and "all" or id,
  1613.                 from = id,
  1614.                 dir = "in",
  1615.                 text = msg.text:sub(1, 200),
  1616.                 label = type(msg.label) == "string" and msg.label:sub(1, 12) or nil,
  1617.                 time = os.epoch and os.epoch("utc") or 0,
  1618.                 read = false,
  1619.             })
  1620.             os.queueEvent("pocketos_message")
  1621.         end
  1622.     end
  1623. end
  1624.  
  1625. ----------------------------------------------------- location service --
  1626.  
  1627. -- The one place gps.locate is called: keeps ctx.fix fresh for the GPS
  1628. -- app (two coroutines locating at once trample the shared GPS channel)
  1629. -- and reports our position to the tracker computer from config.lua.
  1630. -- Apps can queue a "pocketos_locate" event to request a fix right away.
  1631. local function locator()
  1632.     if not ctx.modem then
  1633.         while true do sleep(3600) end
  1634.     end
  1635.     local target = tonumber(ctx.config and ctx.config.tracker)
  1636.     if target == os.getComputerID() then target = nil end
  1637.     local every = tonumber(ctx.config and ctx.config.trackEvery) or 15
  1638.     local lastSent = -math.huge
  1639.     while true do
  1640.         local x, y, z = gps.locate(2)
  1641.         if x then
  1642.             ctx.fix = {
  1643.                 x = math.floor(x + 0.5),
  1644.                 y = math.floor(y + 0.5),
  1645.                 z = math.floor(z + 0.5),
  1646.                 at = os.clock(),
  1647.             }
  1648.             os.queueEvent("pocketos_fix")
  1649.             if target and os.clock() - lastSent >= every then
  1650.                 rednet.send(target, {
  1651.                     id = os.getComputerID(),
  1652.                     label = os.getComputerLabel(),
  1653.                     x = ctx.fix.x,
  1654.                     y = ctx.fix.y,
  1655.                     z = ctx.fix.z,
  1656.                 }, "pocketos.track")
  1657.                 lastSent = os.clock()
  1658.             end
  1659.         end
  1660.         local t = os.startTimer(5)
  1661.         while true do
  1662.             local e, id = os.pullEvent()
  1663.             if e == "timer" and id == t then
  1664.                 break
  1665.             elseif e == "pocketos_locate" then
  1666.                 os.cancelTimer(t)
  1667.                 break
  1668.             end
  1669.         end
  1670.     end
  1671. end
  1672.  
  1673. -------------------------------------------------- remote shell server --
  1674.  
  1675. -- A virtual terminal that records everything written to it, so a
  1676. -- command's output can be captured and sent back to a developer.
  1677. local function captureTerm()
  1678.     local lines = { "" }
  1679.     local cx, cy = 1, 1
  1680.     local t = {}
  1681.     local function ensure(y)
  1682.         for i = #lines + 1, y do lines[i] = "" end
  1683.     end
  1684.     function t.write(s)
  1685.         s = tostring(s)
  1686.         ensure(cy)
  1687.         local line = lines[cy]
  1688.         if #line < cx - 1 then line = line .. string.rep(" ", cx - 1 - #line) end
  1689.         lines[cy] = line:sub(1, cx - 1) .. s .. line:sub(cx + #s)
  1690.         cx = cx + #s
  1691.     end
  1692.     function t.blit(s) t.write(s) end
  1693.     function t.getCursorPos() return cx, cy end
  1694.     function t.setCursorPos(x, y) cx, cy = math.floor(x), math.floor(y); ensure(cy) end
  1695.     function t.getSize() return 51, 19 end
  1696.     function t.getTextColor() return colors.white end
  1697.     function t.getBackgroundColor() return colors.black end
  1698.     function t.isColor() return true end
  1699.     function t.scroll(n)
  1700.         for _ = 1, n do table.remove(lines, 1); lines[#lines + 1] = "" end
  1701.     end
  1702.     function t.clear() lines = { "" }; cx, cy = 1, 1 end
  1703.     function t.clearLine() lines[cy] = "" end
  1704.     function t.setCursorBlink() end
  1705.     function t.setTextColor() end
  1706.     function t.setBackgroundColor() end
  1707.     function t.getPaletteColor() return 1, 1, 1 end
  1708.     -- British spellings CC also expects
  1709.     t.setTextColour = t.setTextColor
  1710.     t.setBackgroundColour = t.setBackgroundColor
  1711.     t.getTextColour = t.getTextColor
  1712.     t.getBackgroundColour = t.getBackgroundColor
  1713.     t.isColour = t.isColor
  1714.     t.getPaletteColour = t.getPaletteColor
  1715.     local function text()
  1716.         local out = {}
  1717.         for _, l in ipairs(lines) do out[#out + 1] = (l:gsub("%s+$", "")) end
  1718.         while #out > 0 and out[#out] == "" do out[#out] = nil end
  1719.         return table.concat(out, "\n")
  1720.     end
  1721.     return t, text
  1722. end
  1723.  
  1724. local function runCaptured(cmd)
  1725.     local cap, getText = captureTerm()
  1726.     local prev = term.redirect(cap)
  1727.     local finished = false
  1728.     pcall(parallel.waitForAny,
  1729.         function()
  1730.             pcall(ctx.shell and ctx.shell.run or shell.run, cmd)
  1731.             finished = true
  1732.         end,
  1733.         function() sleep(8) end) -- guard against interactive/hanging programs
  1734.     term.redirect(prev)
  1735.     local out = getText()
  1736.     if not finished then
  1737.         out = out .. (out == "" and "" or "\n") .. "[timed out after 8s]"
  1738.     end
  1739.     if out == "" then out = "(no output)" end
  1740.     return out
  1741. end
  1742.  
  1743. -- Answers developer requests from any device running the Dev app.
  1744. -- Every PocketOS device is remotely reachable, dev mode or not.
  1745. local function devServer()
  1746.     while true do
  1747.         local id, msg = rednet.receive("pocketos.dev")
  1748.         -- privileged commands are honoured only from admin IDs
  1749.         if type(msg) == "table" and ctx.isAdmin(id) then
  1750.             if msg.type == "ping" then
  1751.                 rednet.send(id, {
  1752.                     ok = true, label = os.getComputerLabel(), id = os.getComputerID(),
  1753.                     version = ctx.version,
  1754.                 }, "pocketos.dev")
  1755.             elseif msg.type == "exec" and type(msg.cmd) == "string" then
  1756.                 local out = runCaptured(msg.cmd:sub(1, 200))
  1757.                 rednet.send(id, { ok = true, output = out:sub(1, 4000) }, "pocketos.dev")
  1758.             elseif msg.type == "ban" then
  1759.                 ctx.banned = {
  1760.                     reason = tostring(msg.reason or "No reason given"):sub(1, 200),
  1761.                     by = id,
  1762.                     at = os.epoch and os.epoch("utc") or 0,
  1763.                 }
  1764.                 ctx.save("ban", ctx.banned)
  1765.                 rednet.send(id, { ok = true, banned = true }, "pocketos.dev")
  1766.                 sleep(0.3) -- let the reply leave before we reboot
  1767.                 os.reboot() -- guarantees the lock even mid-app
  1768.             elseif msg.type == "unban" then
  1769.                 ctx.banned = nil
  1770.                 local p = ctx.dataDir .. "/ban"
  1771.                 if fs.exists(p) then fs.delete(p) end
  1772.                 os.queueEvent("pocketos_unban")
  1773.                 rednet.send(id, { ok = true, banned = false }, "pocketos.dev")
  1774.             elseif msg.type == "banstate" then
  1775.                 rednet.send(id, {
  1776.                     ok = true, banned = ctx.banned ~= nil,
  1777.                     reason = ctx.banned and ctx.banned.reason,
  1778.                 }, "pocketos.dev")
  1779.             end
  1780.         end
  1781.     end
  1782. end
  1783.  
  1784. ------------------------------------------------------------------ boot --
  1785.  
  1786. local function splash()
  1787.     ui.clear(colors.white)
  1788.     ui.center(math.floor(ui.h / 2) - 1, "PocketOS", colors.black, colors.white)
  1789.     local x0 = math.floor((ui.w - 5) / 2) + 1
  1790.     local y0 = math.floor(ui.h / 2) + 1
  1791.     ui.text(x0, y0, "\7 \7 \7", colors.lightGray, colors.white)
  1792.     for i = 0, 2 do
  1793.         sleep(0.25)
  1794.         ui.text(x0 + i * 2, y0, "\7", colors.black, colors.white)
  1795.     end
  1796.     sleep(0.2)
  1797. end
  1798.  
  1799. ui.init()
  1800. splash()
  1801. while true do
  1802.     local ok, err = pcall(parallel.waitForAny, home, listener, locator, devServer)
  1803.     if ctx.banned then
  1804.         -- A banned device can't escape to the shell: if Ctrl+T killed a
  1805.         -- background coroutine, loop straight back into the lock screen.
  1806.         sleep(0.1)
  1807.     else
  1808.         ui.restore()
  1809.         if not ok and not tostring(err):match("Terminated") then
  1810.             error(err, 0)
  1811.         end
  1812.         break
  1813.     end
  1814. end
  1815. ]==],
  1816. ["pocketos/lib/ui.lua"] = [==[
  1817. -- PocketOS shared UI helpers + Apple-flavoured colour palette.
  1818.  
  1819. local ui = {}
  1820.  
  1821. ui.w, ui.h = term.getSize()
  1822.  
  1823. -- Clock preferences, set from saved settings at boot (see main.lua).
  1824. ui.tzOffset = 0   -- hours offset from UTC
  1825. ui.time24 = true  -- 24-hour vs 12-hour display
  1826.  
  1827. local PALETTE = {
  1828.     [colors.white]     = 0xF5F5F7, -- iOS background
  1829.     [colors.lightGray] = 0xDDDDE2,
  1830.     [colors.gray]      = 0x8E8E93,
  1831.     [colors.black]     = 0x1D1D1F,
  1832.     [colors.blue]      = 0x0A84FF,
  1833.     [colors.green]     = 0x34C759,
  1834.     [colors.lime]      = 0x84E36B,
  1835.     [colors.red]       = 0xFF3B30,
  1836.     [colors.orange]    = 0xFF9500,
  1837.     [colors.yellow]    = 0xFFCC00,
  1838.     [colors.purple]    = 0xAF52DE,
  1839.     [colors.cyan]      = 0x5AC8FA,
  1840.     [colors.pink]      = 0xFF7AA2,
  1841.     [colors.magenta]   = 0x5E5CE6, -- iOS indigo (AI app)
  1842.     [colors.lightBlue] = 0x74A7FF,
  1843. }
  1844.  
  1845. function ui.init()
  1846.     ui.w, ui.h = term.getSize()
  1847.     if term.setPaletteColour then
  1848.         for c, hex in pairs(PALETTE) do
  1849.             term.setPaletteColour(c, hex)
  1850.         end
  1851.     end
  1852.     term.setCursorBlink(false)
  1853. end
  1854.  
  1855. function ui.restore()
  1856.     if term.setPaletteColour and term.nativePaletteColour then
  1857.         for c in pairs(PALETTE) do
  1858.             term.setPaletteColour(c, term.nativePaletteColour(c))
  1859.         end
  1860.     end
  1861.     term.setBackgroundColor(colors.black)
  1862.     term.setTextColor(colors.white)
  1863.     term.clear()
  1864.     term.setCursorPos(1, 1)
  1865. end
  1866.  
  1867. function ui.fill(x, y, w, h, bg)
  1868.     term.setBackgroundColor(bg)
  1869.     local line = string.rep(" ", w)
  1870.     for i = 0, h - 1 do
  1871.         term.setCursorPos(x, y + i)
  1872.         term.write(line)
  1873.     end
  1874. end
  1875.  
  1876. function ui.clear(bg)
  1877.     ui.fill(1, 1, ui.w, ui.h, bg or colors.white)
  1878. end
  1879.  
  1880. function ui.text(x, y, s, fg, bg)
  1881.     if fg then term.setTextColor(fg) end
  1882.     if bg then term.setBackgroundColor(bg) end
  1883.     term.setCursorPos(x, y)
  1884.     term.write(s)
  1885. end
  1886.  
  1887. function ui.center(y, s, fg, bg)
  1888.     ui.text(math.floor((ui.w - #s) / 2) + 1, y, s, fg, bg)
  1889. end
  1890.  
  1891. function ui.clock()
  1892.     local ok, t = pcall(os.time, "utc")
  1893.     if not ok or type(t) ~= "number" then t = os.time() end
  1894.     t = (t + (ui.tzOffset or 0)) % 24
  1895.     return textutils.formatTime(t, ui.time24 ~= false)
  1896. end
  1897.  
  1898. -- Top bar: "<" back button on the left, centred title, clock (or custom
  1899. -- text) on the right. Back hit test via ui.isBack(x, y).
  1900. function ui.topBar(title, right, rightColor)
  1901.     ui.fill(1, 1, ui.w, 1, colors.white)
  1902.     ui.text(1, 1, " < ", colors.blue, colors.white)
  1903.     ui.center(1, title, colors.black, colors.white)
  1904.     right = right or ui.clock()
  1905.     ui.text(ui.w - #right, 1, right, rightColor or colors.gray, colors.white)
  1906. end
  1907.  
  1908. function ui.isBack(x, y)
  1909.     return y == 1 and x <= 3
  1910. end
  1911.  
  1912. function ui.button(x, y, label, bg, fg)
  1913.     ui.text(x, y, " " .. label .. " ", fg or colors.white, bg or colors.blue)
  1914.     return { x1 = x, y1 = y, x2 = x + #label + 1, y2 = y }
  1915. end
  1916.  
  1917. function ui.hit(r, x, y)
  1918.     return r and x >= r.x1 and x <= r.x2 and y >= r.y1 and y <= r.y2
  1919. end
  1920.  
  1921. -- A filled panel with a soft drop shadow and rounded corners, for tiles,
  1922. -- cards and dialogs. `under` is the colour behind the panel that shows
  1923. -- through the rounded corners and the shadow gaps (default page white).
  1924. function ui.card(x, y, w, h, bg, under)
  1925.     under = under or colors.white
  1926.     if w >= 3 and h >= 2 then
  1927.         ui.fill(x + 1, y + h, w, 1, colors.lightGray) -- shadow below
  1928.         ui.fill(x + w, y + 1, 1, h, colors.lightGray) -- shadow right
  1929.     end
  1930.     ui.fill(x, y, w, h, bg)
  1931.     -- knock out the four corners so the panel reads as rounded
  1932.     ui.text(x, y, " ", under, under)
  1933.     ui.text(x + w - 1, y, " ", under, under)
  1934.     ui.text(x, y + h - 1, " ", under, under)
  1935.     ui.text(x + w - 1, y + h - 1, " ", under, under)
  1936. end
  1937.  
  1938. -- Touch-friendly single-line text input. Returns the typed string on
  1939. -- Enter, or nil if the user taps the back arrow (top-left) to cancel -
  1940. -- so every screen using it keeps a working back button. Supports
  1941. -- Backspace and Ctrl+V paste. opts: default, width, fg, bg, mask.
  1942. function ui.input(x, y, opts)
  1943.     opts = opts or {}
  1944.     local s = tostring(opts.default or "")
  1945.     local width = opts.width or (ui.w - x)
  1946.     local bg = opts.bg or colors.lightGray
  1947.     local fg = opts.fg or colors.black
  1948.     while true do
  1949.         local shown = opts.mask and string.rep("*", #s) or s
  1950.         if #shown > width - 1 then shown = shown:sub(#shown - (width - 2)) end
  1951.         term.setBackgroundColor(bg)
  1952.         term.setTextColor(fg)
  1953.         term.setCursorPos(x, y)
  1954.         term.write(shown .. string.rep(" ", width - #shown))
  1955.         term.setCursorPos(x + #shown, y)
  1956.         term.setCursorBlink(true)
  1957.         local e, a, b, c = os.pullEvent()
  1958.         if e == "char" then
  1959.             s = s .. a
  1960.         elseif e == "paste" then
  1961.             s = s .. a
  1962.         elseif e == "key" then
  1963.             if a == keys.enter or a == keys.numPadEnter then
  1964.                 term.setCursorBlink(false)
  1965.                 return s
  1966.             elseif a == keys.backspace then
  1967.                 s = s:sub(1, -2)
  1968.             end
  1969.         elseif e == "mouse_click" and ui.isBack(b, c) then
  1970.             term.setCursorBlink(false)
  1971.             return nil
  1972.         end
  1973.     end
  1974. end
  1975.  
  1976. function ui.wrap(s, width)
  1977.     local lines, line = {}, ""
  1978.     for word in tostring(s):gmatch("%S+") do
  1979.         if #line == 0 then
  1980.             line = word
  1981.         elseif #line + 1 + #word <= width then
  1982.             line = line .. " " .. word
  1983.         else
  1984.             lines[#lines + 1] = line
  1985.             line = word
  1986.         end
  1987.         while #line > width do
  1988.             lines[#lines + 1] = line:sub(1, width)
  1989.             line = line:sub(width + 1)
  1990.         end
  1991.     end
  1992.     if #line > 0 then lines[#lines + 1] = line end
  1993.     if #lines == 0 then lines = { "" } end
  1994.     return lines
  1995. end
  1996.  
  1997. -- Modal card with buttons. buttons = { {label, color}, ... }
  1998. -- Returns the index of the tapped button (Enter picks button 1).
  1999. function ui.dialog(title, lines, buttons)
  2000.     local w = 22
  2001.     local h = #lines + 5
  2002.     local x = math.floor((ui.w - w) / 2) + 1
  2003.     local y = math.floor((ui.h - h) / 2) + 1
  2004.     -- soft drop shadow
  2005.     ui.fill(x + 1, y + h, w, 1, colors.gray)
  2006.     ui.fill(x + w, y + 1, 1, h, colors.gray)
  2007.     -- white body with a coloured title bar
  2008.     ui.fill(x, y, w, h, colors.white)
  2009.     ui.fill(x, y, w, 1, colors.blue)
  2010.     ui.text(x, y, " ", colors.white, colors.white) -- round top corners
  2011.     ui.text(x + w - 1, y, " ", colors.white, colors.white)
  2012.     ui.text(x + math.floor((w - #title) / 2), y, title, colors.white, colors.blue)
  2013.     for i, s in ipairs(lines) do
  2014.         ui.text(x + math.floor((w - #s) / 2), y + 1 + i, s, colors.gray, colors.white)
  2015.     end
  2016.     local regions = {}
  2017.     local total = #buttons - 1
  2018.     for _, b in ipairs(buttons) do total = total + #b[1] + 2 end
  2019.     local bx = x + math.floor((w - total) / 2)
  2020.     local by = y + h - 2
  2021.     for i, b in ipairs(buttons) do
  2022.         regions[i] = ui.button(bx, by, b[1], b[2])
  2023.         bx = bx + #b[1] + 3
  2024.     end
  2025.     while true do
  2026.         local e, p1, p2, p3 = os.pullEvent()
  2027.         if e == "mouse_click" then
  2028.             for i, r in ipairs(regions) do
  2029.                 if ui.hit(r, p2, p3) then return i end
  2030.             end
  2031.         elseif e == "key" and p1 == keys.enter then
  2032.             return 1
  2033.         end
  2034.     end
  2035. end
  2036.  
  2037. return ui
  2038. ]==],
  2039. ["pocketos/lib/nbs.lua"] = [==[
  2040. -- PocketOS lib: NBS (Note Block Studio) parsing + speaker playback.
  2041. -- Supports classic .nbs and OpenNBS versions 1-5.
  2042.  
  2043. local nbs = {}
  2044.  
  2045. local INSTRUMENTS = {
  2046.     [0] = "harp", "bass", "basedrum", "snare", "hat", "guitar", "flute",
  2047.     "bell", "chime", "xylophone", "iron_xylophone", "cow_bell",
  2048.     "didgeridoo", "bit", "banjo", "pling",
  2049. }
  2050.  
  2051. function nbs.parse(path)
  2052.     local f = fs.open(path, "rb")
  2053.     if not f then return nil, "Can't open file" end
  2054.  
  2055.     local function u8()
  2056.         return f.read() or 0
  2057.     end
  2058.     local function u16()
  2059.         return u8() + u8() * 256
  2060.     end
  2061.     local function u32()
  2062.         return u16() + u16() * 65536
  2063.     end
  2064.     local function str()
  2065.         local n = u32()
  2066.         if n <= 0 or n > 100000 then return "" end
  2067.         return f.read(n) or ""
  2068.     end
  2069.  
  2070.     local length = u16()
  2071.     local version = 0
  2072.     if length == 0 then -- OpenNBS marker
  2073.         version = u8()
  2074.         u8() -- vanilla instrument count
  2075.         if version >= 3 then length = u16() end
  2076.     end
  2077.     u16() -- layer count
  2078.     local name = str()
  2079.     local author = str()
  2080.     local origAuthor = str()
  2081.     str() -- description
  2082.     local tempo = u16() -- (ticks per second) * 100
  2083.     u8() -- auto save
  2084.     u8() -- auto save duration
  2085.     u8() -- time signature
  2086.     u32() u32() u32() u32() u32() -- editor stats
  2087.     str() -- imported file name
  2088.     if version >= 4 then
  2089.         u8() -- loop on
  2090.         u8() -- max loop count
  2091.         u16() -- loop start tick
  2092.     end
  2093.  
  2094.     local groups = {}
  2095.     local tick = -1
  2096.     local maxTick = 0
  2097.     while true do
  2098.         local jump = u16()
  2099.         if jump == 0 then break end
  2100.         tick = tick + jump
  2101.         local notes = {}
  2102.         while true do
  2103.             local lj = u16()
  2104.             if lj == 0 then break end
  2105.             local inst = u8()
  2106.             local key = u8()
  2107.             local vel = 100
  2108.             if version >= 4 then
  2109.                 vel = u8()
  2110.                 u8() -- panning
  2111.                 u16() -- fine pitch
  2112.             end
  2113.             if #notes < 8 then -- speakers max out at 8 notes per tick
  2114.                 notes[#notes + 1] = { inst = inst, key = key, vel = vel }
  2115.             end
  2116.         end
  2117.         groups[#groups + 1] = { tick = tick, notes = notes }
  2118.         maxTick = tick
  2119.     end
  2120.     f.close()
  2121.  
  2122.     if tempo == 0 then tempo = 1000 end
  2123.     if name == "" then
  2124.         name = fs.getName(path):gsub("%.nbs$", "")
  2125.     end
  2126.     if author == "" then author = origAuthor end
  2127.  
  2128.     return {
  2129.         name = name,
  2130.         author = author,
  2131.         tempo = tempo / 100, -- NBS ticks per second
  2132.         length = math.max(length, maxTick + 1),
  2133.         groups = groups,
  2134.     }
  2135. end
  2136.  
  2137. -- Event-driven player. Feed every event to p.onEvent(e, id); it returns
  2138. -- true when it consumed a timer event. Uses os.clock() so timing
  2139. -- self-corrects instead of drifting.
  2140. function nbs.newPlayer(song, speaker)
  2141.     local spt = 1 / song.tempo -- seconds per NBS tick
  2142.     local p = {
  2143.         index = 1,
  2144.         timer = nil,
  2145.         playing = false,
  2146.         pauseAt = 0,
  2147.         done = false,
  2148.         t0 = 0,
  2149.     }
  2150.  
  2151.     function p.duration()
  2152.         return song.length * spt
  2153.     end
  2154.  
  2155.     function p.elapsed()
  2156.         local t
  2157.         if p.playing then t = os.clock() - p.t0 else t = p.pauseAt end
  2158.         return math.max(0, math.min(t, p.duration()))
  2159.     end
  2160.  
  2161.     function p.progress()
  2162.         local d = p.duration()
  2163.         if d <= 0 then return 0 end
  2164.         return p.elapsed() / d
  2165.     end
  2166.  
  2167.     local function schedule()
  2168.         local g = song.groups[p.index]
  2169.         if not g then
  2170.             local left = p.duration() - (os.clock() - p.t0)
  2171.             if left > 0.05 then
  2172.                 p.timer = os.startTimer(left)
  2173.             else
  2174.                 p.playing = false
  2175.                 p.done = true
  2176.                 p.pauseAt = p.duration()
  2177.                 p.timer = nil
  2178.             end
  2179.             return
  2180.         end
  2181.         local dt = g.tick * spt - (os.clock() - p.t0)
  2182.         p.timer = os.startTimer(math.max(0, dt))
  2183.     end
  2184.  
  2185.     function p.begin()
  2186.         p.done = false
  2187.         p.playing = true
  2188.         p.t0 = os.clock() - (p.pauseAt or 0)
  2189.         schedule()
  2190.     end
  2191.  
  2192.     function p.stop()
  2193.         if p.playing then p.pauseAt = os.clock() - p.t0 end
  2194.         p.playing = false
  2195.         if p.timer then os.cancelTimer(p.timer) end
  2196.         p.timer = nil
  2197.     end
  2198.  
  2199.     function p.restart()
  2200.         p.stop()
  2201.         p.index = 1
  2202.         p.pauseAt = 0
  2203.         p.done = false
  2204.         p.begin()
  2205.     end
  2206.  
  2207.     function p.onEvent(e, id)
  2208.         if e ~= "timer" or id ~= p.timer or not p.playing then return false end
  2209.         local now = os.clock() - p.t0
  2210.         local g = song.groups[p.index]
  2211.         if not g and now >= p.duration() - 0.01 then
  2212.             p.playing = false
  2213.             p.done = true
  2214.             p.pauseAt = p.duration()
  2215.             p.timer = nil
  2216.             return true
  2217.         end
  2218.         while g and g.tick * spt <= now + 0.01 do
  2219.             if speaker then
  2220.                 for _, n in ipairs(g.notes) do
  2221.                     -- MIDI songs carry instName/pitch directly; NBS
  2222.                     -- notes get out-of-range keys folded by octaves
  2223.                     local inst = n.instName or INSTRUMENTS[n.inst] or "harp"
  2224.                     local pitch = n.pitch
  2225.                     if not pitch then
  2226.                         pitch = (n.key or 45) - 33
  2227.                         while pitch < 0 do pitch = pitch + 12 end
  2228.                         while pitch > 24 do pitch = pitch - 12 end
  2229.                     end
  2230.                     local vol = math.min(3, ((n.vel or 100) / 100) * 2)
  2231.                     pcall(speaker.playNote, inst, vol, pitch)
  2232.                 end
  2233.             end
  2234.             p.index = p.index + 1
  2235.             g = song.groups[p.index]
  2236.         end
  2237.         schedule()
  2238.         return true
  2239.     end
  2240.  
  2241.     return p
  2242. end
  2243.  
  2244. return nbs
  2245. ]==],
  2246. ["pocketos/lib/midi.lua"] = [==[
  2247. -- PocketOS lib: MIDI (.mid) parsing for note block playback.
  2248. -- Converts a Standard MIDI File into the same song table the NBS
  2249. -- player uses. Notes are spread across bass/harp/bell instruments so
  2250. -- about six octaves play correctly instead of note blocks' usual two;
  2251. -- channel 10 percussion maps to basedrum/snare/hat.
  2252.  
  2253. local midi = {}
  2254.  
  2255. function midi.parse(path)
  2256.     local f = fs.open(path, "rb")
  2257.     if not f then return nil, "Can't open file" end
  2258.     local data = f.readAll() or ""
  2259.     f.close()
  2260.     if #data < 14 then return nil, "Not a MIDI file" end
  2261.     if #data > 400000 then return nil, "MIDI file too large" end
  2262.  
  2263.     local pos = 1
  2264.     local function u8()
  2265.         local b = data:byte(pos)
  2266.         pos = pos + 1
  2267.         return b or 0
  2268.     end
  2269.     local function u16() return u8() * 256 + u8() end
  2270.     local function u32() return u16() * 65536 + u16() end
  2271.     local function chunk(n)
  2272.         local s = data:sub(pos, pos + n - 1)
  2273.         pos = pos + n
  2274.         return s
  2275.     end
  2276.     local function vlq()
  2277.         local v = 0
  2278.         for _ = 1, 4 do
  2279.             local b = u8()
  2280.             v = v * 128 + (b % 128)
  2281.             if b < 128 then break end
  2282.         end
  2283.         return v
  2284.     end
  2285.  
  2286.     if chunk(4) ~= "MThd" then return nil, "Not a MIDI file" end
  2287.     local hlen = u32()
  2288.     chunk(2) -- format
  2289.     local ntracks = u16()
  2290.     local division = u16()
  2291.     if hlen > 6 then pos = pos + hlen - 6 end
  2292.     if division >= 0x8000 then return nil, "SMPTE MIDI unsupported" end
  2293.     if division == 0 then division = 96 end
  2294.  
  2295.     local events = {}
  2296.     local title
  2297.  
  2298.     for _ = 1, ntracks do
  2299.         if pos + 8 > #data then break end
  2300.         local id = chunk(4)
  2301.         local len = u32()
  2302.         local endPos = pos + len
  2303.         if id ~= "MTrk" then
  2304.             pos = endPos
  2305.         else
  2306.             local tick = 0
  2307.             local status = 0
  2308.             while pos < endPos do
  2309.                 tick = tick + vlq()
  2310.                 local b = u8()
  2311.                 if b < 0x80 then
  2312.                     if status < 0x80 then break end -- corrupt track
  2313.                     pos = pos - 1
  2314.                     b = status -- running status
  2315.                 else
  2316.                     status = b
  2317.                 end
  2318.                 local hi = math.floor(b / 16)
  2319.                 if b == 0xFF then
  2320.                     status = 0
  2321.                     local mt = u8()
  2322.                     local mlen = vlq()
  2323.                     if mt == 0x51 and mlen == 3 then
  2324.                         events[#events + 1] = {
  2325.                             tick = tick,
  2326.                             tempo = u8() * 65536 + u8() * 256 + u8(),
  2327.                         }
  2328.                     elseif mt == 0x03 and not title and mlen > 0 and mlen < 40 then
  2329.                         title = chunk(mlen):gsub("[^%w%p ]", "")
  2330.                     else
  2331.                         pos = pos + mlen
  2332.                     end
  2333.                 elseif b == 0xF0 or b == 0xF7 then
  2334.                     status = 0
  2335.                     pos = pos + vlq()
  2336.                 elseif hi == 9 then -- note on
  2337.                     local note, vel = u8(), u8()
  2338.                     if vel > 0 then
  2339.                         events[#events + 1] = {
  2340.                             tick = tick, ch = b % 16, note = note, vel = vel,
  2341.                         }
  2342.                     end
  2343.                 elseif hi == 8 or hi == 10 or hi == 11 or hi == 14 then
  2344.                     u8() u8()
  2345.                 elseif hi == 12 or hi == 13 then
  2346.                     u8()
  2347.                 else
  2348.                     break -- unexpected byte; skip rest of track
  2349.                 end
  2350.             end
  2351.             pos = endPos
  2352.         end
  2353.     end
  2354.  
  2355.     if #events == 0 then return nil, "No notes found" end
  2356.  
  2357.     table.sort(events, function(a, b)
  2358.         if a.tick ~= b.tick then return a.tick < b.tick end
  2359.         return (a.tempo ~= nil) and (b.tempo == nil)
  2360.     end)
  2361.  
  2362.     -- walk events, converting MIDI ticks to seconds (honouring tempo
  2363.     -- changes), then to speaker game-ticks (20 per second)
  2364.     local byTick = {}
  2365.     local uspq = 500000 -- microseconds per quarter note (120 bpm)
  2366.     local lastTick, seconds = 0, 0
  2367.     local maxGt = 0
  2368.     for _, ev in ipairs(events) do
  2369.         seconds = seconds + (ev.tick - lastTick) * uspq / (division * 1000000)
  2370.         lastTick = ev.tick
  2371.         if ev.tempo then
  2372.             uspq = ev.tempo
  2373.         else
  2374.             local inst, pitch
  2375.             if ev.ch == 9 then -- percussion channel
  2376.                 if ev.note == 35 or ev.note == 36 then
  2377.                     inst, pitch = "basedrum", 6
  2378.                 elseif ev.note == 38 or ev.note == 40 then
  2379.                     inst, pitch = "snare", 12
  2380.                 else
  2381.                     inst, pitch = "hat", 12
  2382.                 end
  2383.             else
  2384.                 -- bass covers MIDI 30-54, harp 54-78, bell 78-102;
  2385.                 -- fold anything outside by octaves
  2386.                 local n = ev.note
  2387.                 while n < 30 do n = n + 12 end
  2388.                 while n > 102 do n = n - 12 end
  2389.                 if n <= 54 then
  2390.                     inst, pitch = "bass", n - 30
  2391.                 elseif n <= 78 then
  2392.                     inst, pitch = "harp", n - 54
  2393.                 else
  2394.                     inst, pitch = "bell", n - 78
  2395.                 end
  2396.             end
  2397.             local gt = math.floor(seconds * 20 + 0.5)
  2398.             local g = byTick[gt]
  2399.             if not g then
  2400.                 g = {}
  2401.                 byTick[gt] = g
  2402.             end
  2403.             if #g < 8 then -- speakers play at most 8 notes per tick
  2404.                 g[#g + 1] = {
  2405.                     instName = inst,
  2406.                     pitch = pitch,
  2407.                     vel = math.floor(ev.vel * 100 / 127),
  2408.                 }
  2409.                 if gt > maxGt then maxGt = gt end
  2410.             end
  2411.         end
  2412.     end
  2413.  
  2414.     local ticks = {}
  2415.     for gt in pairs(byTick) do ticks[#ticks + 1] = gt end
  2416.     table.sort(ticks)
  2417.     local groups = {}
  2418.     for _, gt in ipairs(ticks) do
  2419.         groups[#groups + 1] = { tick = gt, notes = byTick[gt] }
  2420.     end
  2421.  
  2422.     local name = title
  2423.     if not name or name == "" then
  2424.         name = fs.getName(path):gsub("%.[^%.]+$", "")
  2425.     end
  2426.  
  2427.     return {
  2428.         name = name,
  2429.         author = "",
  2430.         tempo = 20, -- game-ticks per second
  2431.         length = maxGt + 10,
  2432.         groups = groups,
  2433.     }
  2434. end
  2435.  
  2436. return midi
  2437. ]==],
  2438. ["pocketos/apps/messages.lua"] = [==[
  2439. -- PocketOS app: Messages - iMessage-style chat over rednet.
  2440. -- Needs a wireless modem (use a Wireless Pocket Computer).
  2441. -- Conversations are per computer ID; "all" broadcasts to everyone.
  2442.  
  2443. local M = {}
  2444. local ui, ctx
  2445.  
  2446. local function fmtTime(t)
  2447.     if not t or t == 0 then return "" end
  2448.     local ok, s = pcall(os.date, "%H:%M", math.floor(t / 1000))
  2449.     if ok and type(s) == "string" then return s end
  2450.     return ""
  2451. end
  2452.  
  2453. local function peerName(peer, label)
  2454.     if peer == "all" then return "Everyone" end
  2455.     if label then return label .. " #" .. tostring(peer) end
  2456.     return "#" .. tostring(peer)
  2457. end
  2458.  
  2459. local function conversations()
  2460.     local map, list = {}, {}
  2461.     for i, m in ipairs(ctx.messages) do
  2462.         local key = tostring(m.peer)
  2463.         local cv = map[key]
  2464.         if not cv then
  2465.             cv = { peer = m.peer, unread = 0 }
  2466.             map[key] = cv
  2467.             list[#list + 1] = cv
  2468.         end
  2469.         cv.last = m
  2470.         cv.idx = i
  2471.         if m.label then cv.label = m.label end
  2472.         if m.dir == "in" and not m.read then cv.unread = cv.unread + 1 end
  2473.     end
  2474.     table.sort(list, function(a, b) return a.idx > b.idx end)
  2475.     return list
  2476. end
  2477.  
  2478. local function markRead(peer)
  2479.     local dirty = false
  2480.     for _, m in ipairs(ctx.messages) do
  2481.         if tostring(m.peer) == tostring(peer) and m.dir == "in" and not m.read then
  2482.             m.read = true
  2483.             dirty = true
  2484.         end
  2485.     end
  2486.     if dirty then ctx.save("messages", ctx.messages) end
  2487. end
  2488.  
  2489. local function sendTo(peer, text)
  2490.     local payload = { text = text, label = os.getComputerLabel(), bc = (peer == "all") }
  2491.     if peer == "all" then
  2492.         rednet.broadcast(payload, ctx.protocol)
  2493.     else
  2494.         rednet.send(tonumber(peer), payload, ctx.protocol)
  2495.     end
  2496.     ctx.addMessage({
  2497.         peer = peer, dir = "out", text = text, read = true,
  2498.         time = os.epoch and os.epoch("utc") or 0,
  2499.     })
  2500. end
  2501.  
  2502. local function titleFor(peer)
  2503.     if peer == "all" then return "Everyone" end
  2504.     local label
  2505.     for _, m in ipairs(ctx.messages) do
  2506.         if tostring(m.peer) == tostring(peer) and m.label then label = m.label end
  2507.     end
  2508.     return peerName(peer, label)
  2509. end
  2510.  
  2511. local function chatLines(peer)
  2512.     local lines = {}
  2513.     for _, m in ipairs(ctx.messages) do
  2514.         if tostring(m.peer) == tostring(peer) then
  2515.             if peer == "all" and m.dir == "in" then
  2516.                 lines[#lines + 1] = {
  2517.                     text = m.label or ("#" .. tostring(m.from or "?")),
  2518.                     x = 2, fg = colors.gray, bg = colors.white,
  2519.                 }
  2520.             end
  2521.             local wrapped = ui.wrap(m.text, 18)
  2522.             local width = 0
  2523.             for _, s in ipairs(wrapped) do width = math.max(width, #s) end
  2524.             for _, s in ipairs(wrapped) do
  2525.                 local pad = " " .. s .. string.rep(" ", width - #s) .. " "
  2526.                 if m.dir == "out" then
  2527.                     lines[#lines + 1] = { text = pad, x = ui.w - #pad, fg = colors.white, bg = colors.blue }
  2528.                 else
  2529.                     lines[#lines + 1] = { text = pad, x = 2, fg = colors.black, bg = colors.lightGray }
  2530.                 end
  2531.             end
  2532.             lines[#lines + 1] = { spacer = true }
  2533.         end
  2534.     end
  2535.     if #lines > 0 and lines[#lines].spacer then lines[#lines] = nil end
  2536.     return lines
  2537. end
  2538.  
  2539. local function drawChat(peer, scroll)
  2540.     ui.clear(colors.white)
  2541.     ui.topBar(titleFor(peer):sub(1, 14))
  2542.     local lines = chatLines(peer)
  2543.     local top, bottom = 2, ui.h - 2
  2544.     local yy = bottom
  2545.     local i = #lines - scroll
  2546.     while i >= 1 and yy >= top do
  2547.         local L = lines[i]
  2548.         if not L.spacer then
  2549.             ui.text(L.x, yy, L.text, L.fg, L.bg)
  2550.         end
  2551.         yy = yy - 1
  2552.         i = i - 1
  2553.     end
  2554.     if #lines == 0 then
  2555.         ui.center(9, "Say hello!", colors.gray, colors.white)
  2556.     end
  2557.     ui.fill(1, ui.h, ui.w, 1, colors.lightGray)
  2558.     ui.text(2, ui.h, "Message...", colors.gray, colors.lightGray)
  2559.     return #lines
  2560. end
  2561.  
  2562. local function chat(peer)
  2563.     local scroll = 0
  2564.     while true do
  2565.         markRead(peer)
  2566.         local total = drawChat(peer, scroll)
  2567.         local e, b, mx, my = os.pullEvent()
  2568.         if e == "mouse_click" then
  2569.             if ui.isBack(mx, my) then return end
  2570.             if my == ui.h then
  2571.                 ui.fill(1, ui.h, ui.w, 1, colors.white)
  2572.                 local text = ui.input(2, ui.h, { width = ui.w - 2, bg = colors.white })
  2573.                 if text and text ~= "" then
  2574.                     if ctx.modem then
  2575.                         sendTo(peer, text)
  2576.                         scroll = 0
  2577.                     else
  2578.                         ui.dialog("No Modem",
  2579.                             { "A wireless modem", "is needed to send." },
  2580.                             { { "OK", colors.blue } })
  2581.                     end
  2582.                 end
  2583.             end
  2584.         elseif e == "mouse_scroll" then
  2585.             if b > 0 then
  2586.                 scroll = math.max(0, scroll - 3)
  2587.             else
  2588.                 scroll = math.min(math.max(0, total - 1), scroll + 3)
  2589.             end
  2590.         end
  2591.         -- pocketos_message and other events just trigger a redraw
  2592.     end
  2593. end
  2594.  
  2595. local function compose()
  2596.     ui.clear(colors.white)
  2597.     ui.topBar("New Message")
  2598.     ui.text(2, 4, "Send to computer ID,", colors.gray, colors.white)
  2599.     ui.text(2, 5, "or 'all' to broadcast:", colors.gray, colors.white)
  2600.     ui.fill(2, 7, ui.w - 2, 1, colors.lightGray)
  2601.     local s = ui.input(3, 7, { width = ui.w - 4 })
  2602.     if s == nil then return nil end
  2603.     s = s:gsub("%s+", "")
  2604.     if s == "" then return nil end
  2605.     if s:lower() == "all" then return "all" end
  2606.     local n = tonumber(s)
  2607.     if n and n >= 0 then return math.floor(n) end
  2608.     ui.center(10, "That's not a valid ID", colors.red, colors.white)
  2609.     sleep(1.2)
  2610.     return nil
  2611. end
  2612.  
  2613. function M.run(u, c)
  2614.     ui, ctx = u, c
  2615.     local scroll = 0
  2616.     while true do
  2617.         local convs = conversations()
  2618.         ui.clear(colors.white)
  2619.         ui.topBar("Messages", "+", colors.blue)
  2620.         local y = 3
  2621.         local rows = {}
  2622.         if not ctx.modem then
  2623.             ui.center(y, "No wireless modem!", colors.red, colors.white)
  2624.             y = y + 2
  2625.         end
  2626.         for i = scroll + 1, #convs do
  2627.             local cv = convs[i]
  2628.             if y + 1 > ui.h - 2 then break end
  2629.             if cv.unread > 0 then
  2630.                 ui.text(1, y, "\7", colors.blue, colors.white)
  2631.             end
  2632.             ui.text(3, y, peerName(cv.peer, cv.label):sub(1, 15), colors.black, colors.white)
  2633.             local t = fmtTime(cv.last.time)
  2634.             if #t > 0 then ui.text(ui.w - #t, y, t, colors.gray, colors.white) end
  2635.             local prev = (cv.last.dir == "out" and "You: " or "") .. cv.last.text
  2636.             ui.text(3, y + 1, prev:sub(1, ui.w - 3), colors.gray, colors.white)
  2637.             rows[#rows + 1] = { y1 = y, y2 = y + 1, peer = cv.peer }
  2638.             y = y + 3
  2639.         end
  2640.         if #convs == 0 then
  2641.             ui.center(9, "No messages yet", colors.gray, colors.white)
  2642.             ui.center(11, "Tap + to say hi", colors.gray, colors.white)
  2643.         end
  2644.         ui.center(ui.h, "My ID: " .. os.getComputerID(), colors.gray, colors.white)
  2645.  
  2646.         local e, b, mx, my = os.pullEvent()
  2647.         if e == "mouse_click" then
  2648.             if ui.isBack(mx, my) then return end
  2649.             if my == 1 and mx >= ui.w - 3 then
  2650.                 local peer = compose()
  2651.                 if peer ~= nil then chat(peer) end
  2652.             else
  2653.                 for _, r in ipairs(rows) do
  2654.                     if my >= r.y1 and my <= r.y2 then
  2655.                         chat(r.peer)
  2656.                         break
  2657.                     end
  2658.                 end
  2659.             end
  2660.         elseif e == "mouse_scroll" then
  2661.             scroll = math.max(0, math.min(scroll + b, math.max(0, #convs - 1)))
  2662.         end
  2663.         -- pocketos_message just triggers a redraw of the list
  2664.     end
  2665. end
  2666.  
  2667. return M
  2668. ]==],
  2669. ["pocketos/apps/music.lua"] = [==[
  2670. -- PocketOS app: Music - plays Note Block Studio (.nbs) and MIDI (.mid)
  2671. -- songs, plus REAL audio streamed from .dfpwm links (only the link is
  2672. -- saved - nothing is downloaded to the pocket). Files live in /music;
  2673. -- import by drag-and-drop or direct URL. MIDI notes are spread across
  2674. -- bass/harp/bell for ~6 octaves of range.
  2675. -- Sound needs a speaker (noisy pocket computer).
  2676.  
  2677. local M = {}
  2678. local ui, ctx, nbs, midi, speaker
  2679.  
  2680. local DIR = "/music"
  2681.  
  2682. local function isMidi(name)
  2683.     name = name:lower()
  2684.     return name:match("%.mid$") ~= nil or name:match("%.midi$") ~= nil
  2685. end
  2686.  
  2687. local function isSong(name)
  2688.     return name:lower():match("%.nbs$") ~= nil or isMidi(name)
  2689. end
  2690.  
  2691. local function stripExt(name)
  2692.     return (name:gsub("%.[^%.]+$", ""))
  2693. end
  2694.  
  2695. local function parseSong(fname)
  2696.     if isMidi(fname) then
  2697.         return midi.parse(DIR .. "/" .. fname)
  2698.     end
  2699.     return nbs.parse(DIR .. "/" .. fname)
  2700. end
  2701.  
  2702. local function fmtT(s)
  2703.     s = math.max(0, math.floor(s + 0.5))
  2704.     return string.format("%d:%02d", math.floor(s / 60), s % 60)
  2705. end
  2706.  
  2707. local function songList()
  2708.     local out = {}
  2709.     for _, f in ipairs(fs.list(DIR)) do
  2710.         if isSong(f) then out[#out + 1] = f end
  2711.     end
  2712.     table.sort(out)
  2713.     return out
  2714. end
  2715.  
  2716. local function import()
  2717.     ui.clear(colors.white)
  2718.     ui.topBar("Import")
  2719.     ui.text(2, 3, "Link to .nbs/.mid to", colors.gray, colors.white)
  2720.     ui.text(2, 4, "download, or .dfpwm", colors.gray, colors.white)
  2721.     ui.text(2, 5, "audio to stream:", colors.gray, colors.white)
  2722.     ui.text(2, 13, "Tip: dragging a song", colors.lightGray, colors.white)
  2723.     ui.text(2, 14, "file onto the window", colors.lightGray, colors.white)
  2724.     ui.text(2, 15, "is even easier!", colors.lightGray, colors.white)
  2725.     ui.fill(2, 7, ui.w - 2, 1, colors.lightGray)
  2726.     local url = ui.input(3, 7, { width = ui.w - 4 })
  2727.     if url == nil then return end
  2728.     url = url:gsub("^%s+", ""):gsub("%s+$", "")
  2729.     if url == "" then return end
  2730.     local clean = url:lower():gsub("[?#].*$", "")
  2731.     if not (clean:match("%.nbs$") or clean:match("%.mid$") or clean:match("%.midi$")) then
  2732.         -- not a downloadable song: save the link as an audio stream
  2733.         ui.fill(1, 8, ui.w, ui.h - 7, colors.white)
  2734.         ui.text(2, 9, "Saving as a stream.", colors.gray, colors.white)
  2735.         ui.text(2, 10, "Name it:", colors.gray, colors.white)
  2736.         ui.fill(2, 12, ui.w - 2, 1, colors.lightGray)
  2737.         local name = ui.input(3, 12, { width = ui.w - 4 })
  2738.         if name == nil then return end
  2739.         name = name:gsub("^%s+", ""):gsub("%s+$", ""):sub(1, 20)
  2740.         if name == "" then name = "Stream" end
  2741.         local streams = ctx.load("streams", {})
  2742.         streams[#streams + 1] = { name = name, url = url }
  2743.         ctx.save("streams", streams)
  2744.         ui.center(14, "Stream saved!", colors.green, colors.white)
  2745.         sleep(1)
  2746.         return
  2747.     end
  2748.     if not http then
  2749.         ui.center(9, "HTTP is disabled :(", colors.red, colors.white)
  2750.         sleep(1.5)
  2751.         return
  2752.     end
  2753.     ui.center(9, "Downloading...", colors.gray, colors.white)
  2754.     local ok, res = pcall(http.get, url, nil, true)
  2755.     if not ok or not res then
  2756.         ui.center(11, "Download failed", colors.red, colors.white)
  2757.         sleep(1.5)
  2758.         return
  2759.     end
  2760.     local data = res.readAll() or ""
  2761.     res.close()
  2762.     if #data < 50 then
  2763.         ui.center(11, "That file looks empty", colors.red, colors.white)
  2764.         sleep(1.5)
  2765.         return
  2766.     end
  2767.     local base = url:gsub("[?#].*$", ""):match("([^/]+)$") or "song.nbs"
  2768.     base = base:gsub("%%20", " "):gsub("[^%w%._%- ]", "")
  2769.     if base == "" then base = "song.nbs" end
  2770.     if not isSong(base) then base = base .. ".nbs" end
  2771.     local ext = base:match("(%.[^%.]+)$")
  2772.     local path = DIR .. "/" .. base
  2773.     local n = 1
  2774.     while fs.exists(path) do
  2775.         n = n + 1
  2776.         path = DIR .. "/" .. stripExt(base) .. n .. ext
  2777.     end
  2778.     local f = fs.open(path, "wb")
  2779.     f.write(data)
  2780.     f.close()
  2781.     ui.center(11, "Saved " .. fs.getName(path):sub(1, 18), colors.green, colors.white)
  2782.     sleep(1.2)
  2783. end
  2784.  
  2785. local function nowPlaying(fname)
  2786.     local song, err = parseSong(fname)
  2787.     if not song then
  2788.         ui.dialog("Can't Play", { tostring(err or "Bad .nbs file") }, { { "OK", colors.blue } })
  2789.         return
  2790.     end
  2791.     if not speaker then
  2792.         ui.dialog("No Speaker", { "Craft this pocket", "with a speaker!" }, { { "OK", colors.blue } })
  2793.         return
  2794.     end
  2795.     local pl = nbs.newPlayer(song, speaker)
  2796.  
  2797.     local btn
  2798.     local rst = { x1 = 3, y1 = 16, x2 = 6, y2 = 16 }
  2799.     local function drawControls()
  2800.         ui.fill(1, 16, ui.w, 1, colors.white)
  2801.         local label = pl.done and "Replay" or (pl.playing and "Pause" or "Play")
  2802.         btn = ui.button(math.floor((ui.w - #label - 2) / 2) + 1, 16, label, colors.blue)
  2803.         ui.button(3, 16, "<<", colors.gray)
  2804.     end
  2805.  
  2806.     local function drawProgress()
  2807.         local wb = ui.w - 4
  2808.         local filled = math.min(wb, math.floor(pl.progress() * wb + 0.5))
  2809.         ui.fill(3, 12, wb, 1, colors.lightGray)
  2810.         if filled > 0 then ui.fill(3, 12, filled, 1, colors.blue) end
  2811.         ui.text(3, 13, fmtT(pl.elapsed()), colors.gray, colors.white)
  2812.         local d = fmtT(pl.duration())
  2813.         ui.text(ui.w - 1 - #d, 13, d, colors.gray, colors.white)
  2814.     end
  2815.  
  2816.     ui.clear(colors.white)
  2817.     ui.topBar("Now Playing")
  2818.     local ax = math.floor((ui.w - 10) / 2) + 1
  2819.     ui.fill(ax, 3, 10, 4, colors.red)
  2820.     ui.text(ax + 4, 5, "\14", colors.white, colors.red)
  2821.     ui.center(8, song.name:sub(1, 24), colors.black, colors.white)
  2822.     local by = (song.author and song.author ~= "") and song.author or "Unknown artist"
  2823.     ui.center(9, by:sub(1, 24), colors.gray, colors.white)
  2824.  
  2825.     pl.begin()
  2826.     drawControls()
  2827.     drawProgress()
  2828.  
  2829.     local ut = os.startTimer(0.5)
  2830.     local wasDone = false
  2831.     while true do
  2832.         local e, a, b, c = os.pullEvent()
  2833.         if pl.onEvent(e, a) then
  2834.             if pl.done and not wasDone then
  2835.                 wasDone = true
  2836.                 drawControls()
  2837.                 drawProgress()
  2838.             end
  2839.         elseif e == "timer" and a == ut then
  2840.             drawProgress()
  2841.             ut = os.startTimer(0.5)
  2842.         elseif e == "mouse_click" then
  2843.             if ui.isBack(b, c) then
  2844.                 pl.stop()
  2845.                 return
  2846.             end
  2847.             if ui.hit(btn, b, c) then
  2848.                 if pl.done then
  2849.                     wasDone = false
  2850.                     pl.restart()
  2851.                 elseif pl.playing then
  2852.                     pl.stop()
  2853.                 else
  2854.                     pl.begin()
  2855.                 end
  2856.                 drawControls()
  2857.             elseif ui.hit(rst, b, c) then
  2858.                 wasDone = false
  2859.                 pl.restart()
  2860.                 drawControls()
  2861.                 drawProgress()
  2862.             end
  2863.         end
  2864.     end
  2865. end
  2866.  
  2867. local function getDfpwm()
  2868.     if not ctx.require then return nil end
  2869.     local ok, mod = pcall(ctx.require, "cc.audio.dfpwm")
  2870.     if ok and type(mod) == "table" and mod.make_decoder then return mod end
  2871.     return nil
  2872. end
  2873.  
  2874. -- Streams real audio from a saved .dfpwm link. Nothing is written to
  2875. -- disk; the audio is fetched fresh each play.
  2876. local function streamScreen(streams, idx)
  2877.     local st = streams[idx]
  2878.     if not speaker then
  2879.         ui.dialog("No Speaker", { "Craft this pocket", "with a speaker!" }, { { "OK", colors.blue } })
  2880.         return
  2881.     end
  2882.     if not http then
  2883.         ui.dialog("No HTTP", { "HTTP is disabled in", "the server config." }, { { "OK", colors.blue } })
  2884.         return
  2885.     end
  2886.     local dfpwm = getDfpwm()
  2887.     if not dfpwm then
  2888.         ui.dialog("Too Old", { "This CC version has", "no DFPWM audio." }, { { "OK", colors.blue } })
  2889.         return
  2890.     end
  2891.  
  2892.     local function drawHeader()
  2893.         ui.clear(colors.white)
  2894.         ui.topBar("Streaming", "Del", colors.red)
  2895.         local ax = math.floor((ui.w - 10) / 2) + 1
  2896.         ui.fill(ax, 3, 10, 4, colors.green)
  2897.         ui.text(ax + 4, 5, "\14", colors.white, colors.green)
  2898.         ui.center(8, st.name:sub(1, 24), colors.black, colors.white)
  2899.         ui.center(9, st.url:gsub("^https?://", ""):sub(1, 24), colors.lightGray, colors.white)
  2900.     end
  2901.  
  2902.     local function status(msg, color)
  2903.         ui.fill(1, 11, ui.w, 1, colors.white)
  2904.         if msg then ui.center(11, msg, color, colors.white) end
  2905.     end
  2906.  
  2907.     while true do -- retry loop
  2908.         drawHeader()
  2909.         status("Connecting...", colors.gray)
  2910.         local okGet, res = pcall(http.get, st.url, nil, true)
  2911.         if not (okGet and res) then
  2912.             local pick = ui.dialog("No Connection", { "Couldn't reach", "that link." },
  2913.                 { { "Retry", colors.blue }, { "Back", colors.gray } })
  2914.             if pick ~= 1 then return end
  2915.         else
  2916.             status("Buffering...", colors.gray)
  2917.             local data = res.readAll() or ""
  2918.             res.close()
  2919.  
  2920.             -- Links that aren't real DFPWM decode into slow noise about
  2921.             -- 8x the song's true length - catch the common mistakes.
  2922.             local head = data:sub(1, 16)
  2923.             local problem
  2924.             if head:match("^%s*<") then
  2925.                 problem = { "That's a web page!", "Paste the RAW file", "link instead." }
  2926.             elseif head:sub(1, 4) == "RIFF" then
  2927.                 problem = { "That's a WAV file.", "Convert to .dfpwm at", "music.madefor.cc" }
  2928.             elseif head:sub(1, 3) == "ID3"
  2929.                 or (head:byte(1) == 255 and (head:byte(2) or 0) >= 224) then
  2930.                 problem = { "That's an MP3 file.", "Convert to .dfpwm at", "music.madefor.cc" }
  2931.             elseif head:sub(1, 4) == "OggS" or head:sub(1, 4) == "fLaC" then
  2932.                 problem = { "Not a .dfpwm file.", "Convert to .dfpwm at", "music.madefor.cc" }
  2933.             elseif #data < 100 then
  2934.                 problem = { "That file is empty." }
  2935.             end
  2936.             if problem then
  2937.                 ui.dialog("Bad Audio", problem, { { "OK", colors.blue } })
  2938.                 return
  2939.             end
  2940.  
  2941.             local totalSec = #data * 8 / 48000
  2942.             local decoder = dfpwm.make_decoder()
  2943.             local at = 1
  2944.             local playing = true
  2945.             local buf = nil
  2946.             local samples = 0
  2947.             local shown = -1
  2948.             local btn
  2949.  
  2950.             local function fmt(s)
  2951.                 s = math.max(0, math.floor(s + 0.5))
  2952.                 return string.format("%d:%02d", math.floor(s / 60), s % 60)
  2953.             end
  2954.             local function drawControls()
  2955.                 ui.fill(1, 16, ui.w, 1, colors.white)
  2956.                 local label = playing and "Pause" or "Play"
  2957.                 btn = ui.button(math.floor((ui.w - #label - 2) / 2) + 1, 16, label, colors.blue)
  2958.             end
  2959.             local function drawProgress(force)
  2960.                 local s = math.floor(samples / 48000)
  2961.                 if s == shown and not force then return end
  2962.                 shown = s
  2963.                 local wb = ui.w - 4
  2964.                 local filled = math.min(wb, math.floor(s / math.max(1, totalSec) * wb + 0.5))
  2965.                 ui.fill(3, 12, wb, 1, colors.lightGray)
  2966.                 if filled > 0 then ui.fill(3, 12, filled, 1, colors.green) end
  2967.                 ui.fill(1, 13, ui.w, 1, colors.white)
  2968.                 ui.text(3, 13, fmt(s), colors.gray, colors.white)
  2969.                 local d = fmt(totalSec)
  2970.                 ui.text(ui.w - 1 - #d, 13, d, colors.gray, colors.white)
  2971.             end
  2972.             local function redrawAll()
  2973.                 drawHeader()
  2974.                 status(not playing and "Paused" or nil, colors.gray)
  2975.                 drawControls()
  2976.                 drawProgress(true)
  2977.             end
  2978.  
  2979.             status(nil)
  2980.             drawControls()
  2981.             drawProgress(true)
  2982.  
  2983.             while true do
  2984.                 local idle = true
  2985.                 if playing and not buf and at <= #data then
  2986.                     local chunk = data:sub(at, at + 8191)
  2987.                     at = at + 8192
  2988.                     buf = decoder(chunk)
  2989.                 end
  2990.                 if playing and buf and speaker.playAudio(buf) then
  2991.                     samples = samples + #buf
  2992.                     buf = nil
  2993.                     drawProgress()
  2994.                     idle = false -- keep filling until the speaker is full
  2995.                 elseif playing and not buf and at > #data then
  2996.                     -- all data queued; let the tail play out, then finish
  2997.                     local t = os.startTimer(3)
  2998.                     while true do
  2999.                         local e, id = os.pullEvent()
  3000.                         if (e == "timer" and id == t) or e == "speaker_audio_empty" then
  3001.                             break
  3002.                         end
  3003.                     end
  3004.                     local pick = ui.dialog("Stream Ended", { st.name:sub(1, 18) },
  3005.                         { { "Replay", colors.blue }, { "Back", colors.gray } })
  3006.                     if pick ~= 1 then return end
  3007.                     decoder = dfpwm.make_decoder()
  3008.                     at = 1
  3009.                     samples = 0
  3010.                     playing = true
  3011.                     redrawAll()
  3012.                     idle = false
  3013.                 end
  3014.                 if idle then
  3015.                     local e, a, b, c = os.pullEvent()
  3016.                     if e == "mouse_click" then
  3017.                         if ui.isBack(b, c) then
  3018.                             speaker.stop()
  3019.                             return
  3020.                         elseif c == 1 and b >= ui.w - 4 then
  3021.                             speaker.stop()
  3022.                             local pick = ui.dialog("Delete?", { st.name },
  3023.                                 { { "Delete", colors.red }, { "Keep", colors.gray } })
  3024.                             if pick == 1 then
  3025.                                 table.remove(streams, idx)
  3026.                                 ctx.save("streams", streams)
  3027.                                 return
  3028.                             end
  3029.                             redrawAll()
  3030.                         elseif ui.hit(btn, b, c) then
  3031.                             playing = not playing
  3032.                             if not playing then speaker.stop() end
  3033.                             drawControls()
  3034.                             status(not playing and "Paused" or nil, colors.gray)
  3035.                         end
  3036.                     end
  3037.                 end
  3038.             end
  3039.         end
  3040.     end
  3041. end
  3042.  
  3043. local function jsonDecode(s)
  3044.     local d = textutils.unserialiseJSON or textutils.unserializeJSON
  3045.     if not d then return nil end
  3046.     local ok, v = pcall(d, s)
  3047.     return ok and v or nil
  3048. end
  3049.  
  3050. local function serverURL()
  3051.     local s = ctx.config and ctx.config.musicServer
  3052.     if type(s) == "string" and s ~= "" then return (s:gsub("/+$", "")) end
  3053.     return nil
  3054. end
  3055.  
  3056. -- Streams full-quality audio (real songs) from the music server. The
  3057. -- server transcodes to DFPWM on the fly, so it arrives as a live stream
  3058. -- we decode chunk by chunk - exactly what CC speakers want.
  3059. local function streamOnline(title, author, url, duration)
  3060.     if not speaker then
  3061.         ui.dialog("No Speaker", { "Craft this pocket", "with a speaker!" }, { { "OK", colors.blue } })
  3062.         return
  3063.     end
  3064.     local dfpwm = getDfpwm()
  3065.     if not dfpwm then
  3066.         ui.dialog("Too Old", { "This CC version has", "no DFPWM audio." }, { { "OK", colors.blue } })
  3067.         return
  3068.     end
  3069.  
  3070.     -- saves the song's stream link to the library so it can be replayed
  3071.     -- without searching again (the audio itself is far too big to store)
  3072.     local function saveSong()
  3073.         local streams = ctx.load("streams", {})
  3074.         for _, s in ipairs(streams) do
  3075.             if s.url == url then return false end -- already saved
  3076.         end
  3077.         streams[#streams + 1] = {
  3078.             name = tostring(title):sub(1, 20), url = url,
  3079.             online = true, duration = duration,
  3080.         }
  3081.         ctx.save("streams", streams)
  3082.         return true
  3083.     end
  3084.  
  3085.     ui.clear(colors.white)
  3086.     ui.topBar("Streaming", "Save", colors.blue)
  3087.     local ax = math.floor((ui.w - 10) / 2) + 1
  3088.     ui.fill(ax, 3, 10, 4, colors.green)
  3089.     ui.text(ax + 4, 5, "\14", colors.white, colors.green)
  3090.     ui.center(8, tostring(title):sub(1, 24), colors.black, colors.white)
  3091.     if author and author ~= "" then
  3092.         ui.center(9, tostring(author):sub(1, 24), colors.gray, colors.white)
  3093.     end
  3094.     ui.center(12, "Connecting...", colors.gray, colors.white)
  3095.  
  3096.     local handle, err = http.get(url, nil, true)
  3097.     if not handle then
  3098.         ui.dialog("Can't Stream", ui.wrap(tostring(err or "no response"), 20),
  3099.             { { "OK", colors.blue } })
  3100.         return
  3101.     end
  3102.  
  3103.     local decoder = dfpwm.make_decoder()
  3104.     local playing, quit, finished = true, false, false
  3105.     local samples = 0
  3106.  
  3107.     local function fmt(s)
  3108.         s = math.max(0, math.floor(s + 0.5))
  3109.         return string.format("%d:%02d", math.floor(s / 60), s % 60)
  3110.     end
  3111.     local btn
  3112.     local function drawControls()
  3113.         ui.fill(1, 16, ui.w, 1, colors.white)
  3114.         local label = playing and "Pause" or "Play"
  3115.         btn = ui.button(math.floor((ui.w - #label - 2) / 2) + 1, 16, label, colors.blue)
  3116.     end
  3117.     local function drawProgress()
  3118.         local secs = samples / 48000
  3119.         ui.fill(1, 12, ui.w, 1, colors.white)
  3120.         ui.fill(1, 13, ui.w, 1, colors.white)
  3121.         if duration and duration > 0 then
  3122.             local wb = ui.w - 4
  3123.             local filled = math.min(wb, math.floor(secs / duration * wb + 0.5))
  3124.             ui.fill(3, 12, wb, 1, colors.lightGray)
  3125.             if filled > 0 then ui.fill(3, 12, filled, 1, colors.green) end
  3126.             ui.text(3, 13, fmt(secs), colors.gray, colors.white)
  3127.             local d = fmt(duration)
  3128.             ui.text(ui.w - 1 - #d, 13, d, colors.gray, colors.white)
  3129.         else
  3130.             ui.center(12, fmt(secs), colors.gray, colors.white)
  3131.         end
  3132.     end
  3133.     drawControls()
  3134.     drawProgress()
  3135.  
  3136.     local function player()
  3137.         while not quit do
  3138.             if playing then
  3139.                 local chunk = handle.read(16 * 1024)
  3140.                 if not chunk then finished = true; return end
  3141.                 local buffer = decoder(chunk)
  3142.                 samples = samples + #buffer
  3143.                 while not speaker.playAudio(buffer) do
  3144.                     os.pullEvent("speaker_audio_empty")
  3145.                     if quit or not playing then break end
  3146.                 end
  3147.             else
  3148.                 os.sleep(0.1)
  3149.             end
  3150.         end
  3151.     end
  3152.  
  3153.     local function control()
  3154.         local t = os.startTimer(0.5)
  3155.         while not quit do
  3156.             local e, a, b, c = os.pullEvent()
  3157.             if e == "timer" and a == t then
  3158.                 drawProgress()
  3159.                 if finished then quit = true end
  3160.                 t = os.startTimer(0.5)
  3161.             elseif e == "mouse_click" then
  3162.                 if ui.isBack(b, c) then
  3163.                     quit = true
  3164.                 elseif c == 1 and b >= ui.w - 4 then
  3165.                     -- "Save" in the top bar
  3166.                     local ok2 = saveSong()
  3167.                     ui.fill(1, 14, ui.w, 1, colors.white)
  3168.                     ui.center(14, ok2 and "Saved to library" or "Already saved",
  3169.                         ok2 and colors.green or colors.gray, colors.white)
  3170.                 elseif ui.hit(btn, b, c) then
  3171.                     playing = not playing
  3172.                     if not playing then speaker.stop() end
  3173.                     drawControls()
  3174.                 end
  3175.             end
  3176.         end
  3177.     end
  3178.  
  3179.     parallel.waitForAny(player, control)
  3180.     pcall(handle.close)
  3181.     speaker.stop()
  3182.     if finished and samples == 0 then
  3183.         ui.dialog("No Audio",
  3184.             { "The server sent no", "audio for this track.", "Try another song." },
  3185.             { { "OK", colors.blue } })
  3186.     elseif finished then
  3187.         local pick = ui.dialog("Finished", { tostring(title):sub(1, 18) },
  3188.             { { "Save", colors.green }, { "Done", colors.blue } })
  3189.         if pick == 1 then saveSong() end
  3190.     end
  3191. end
  3192.  
  3193. -- Search the music server and stream a result.
  3194. local function searchOnline()
  3195.     local base = serverURL()
  3196.     if not base then return end
  3197.     ui.clear(colors.white)
  3198.     ui.topBar("Search")
  3199.     ui.text(2, 3, "Search for a song:", colors.gray, colors.white)
  3200.     ui.fill(2, 5, ui.w - 2, 1, colors.lightGray)
  3201.     local q = ui.input(3, 5, { width = ui.w - 4 })
  3202.     if q == nil then return end
  3203.     q = q:gsub("^%s+", ""):gsub("%s+$", "")
  3204.     if q == "" then return end
  3205.     if not http then
  3206.         ui.dialog("No HTTP", { "HTTP is disabled." }, { { "OK", colors.blue } })
  3207.         return
  3208.     end
  3209.     ui.center(8, "Searching...", colors.gray, colors.white)
  3210.     ui.center(9, "(may wake the server)", colors.lightGray, colors.white)
  3211.     local res, err = http.get(base .. "/search?q=" .. textutils.urlEncode(q))
  3212.     if not res then
  3213.         ui.dialog("Can't Reach Server",
  3214.             ui.wrap(tostring(err or "no response"), 20),
  3215.             { { "OK", colors.blue } })
  3216.         return
  3217.     end
  3218.     local body = res.readAll() or ""
  3219.     res.close()
  3220.     local data = jsonDecode(body)
  3221.     if not data then
  3222.         ui.dialog("Bad Response",
  3223.             { "Server replied but", "not with JSON.", "ffmpeg/yt-dlp ok?" },
  3224.             { { "OK", colors.blue } })
  3225.         return
  3226.     end
  3227.     local results = data.results
  3228.     if type(results) ~= "table" or #results == 0 then
  3229.         ui.center(11, "No results found", colors.gray, colors.white)
  3230.         sleep(1.2)
  3231.         return
  3232.     end
  3233.  
  3234.     local scroll = 0
  3235.     while true do
  3236.         ui.clear(colors.white)
  3237.         ui.topBar("Results")
  3238.         local rows = {}
  3239.         local y = 3
  3240.         for i = scroll + 1, #results do
  3241.             if y + 1 > ui.h - 1 then break end
  3242.             local r = results[i]
  3243.             ui.text(2, y, tostring(r.title):sub(1, ui.w - 2), colors.black, colors.white)
  3244.             local sub = tostring(r.author or "")
  3245.             if r.duration and r.duration > 0 then
  3246.                 local d = string.format("%d:%02d", math.floor(r.duration / 60), r.duration % 60)
  3247.                 sub = (sub ~= "" and (sub .. " - ") or "") .. d
  3248.             end
  3249.             ui.text(2, y + 1, sub:sub(1, ui.w - 2), colors.gray, colors.white)
  3250.             rows[#rows + 1] = { y1 = y, y2 = y + 1, r = r }
  3251.             y = y + 3
  3252.         end
  3253.         ui.center(ui.h, "Tap a song to play", colors.lightGray, colors.white)
  3254.  
  3255.         local e, a, b, c = os.pullEvent()
  3256.         if e == "mouse_click" then
  3257.             if ui.isBack(b, c) then return end
  3258.             for _, row in ipairs(rows) do
  3259.                 if c >= row.y1 and c <= row.y2 then
  3260.                     streamOnline(row.r.title, row.r.author,
  3261.                         base .. "/stream?id=" .. textutils.urlEncode(tostring(row.r.id)),
  3262.                         tonumber(row.r.duration))
  3263.                     break
  3264.                 end
  3265.             end
  3266.         elseif e == "mouse_scroll" then
  3267.             scroll = math.max(0, math.min(scroll + a, math.max(0, #results - 1)))
  3268.         end
  3269.     end
  3270. end
  3271.  
  3272. function M.run(u, c)
  3273.     ui, ctx = u, c
  3274.     nbs = dofile("/pocketos/lib/nbs.lua")
  3275.     midi = dofile("/pocketos/lib/midi.lua")
  3276.     speaker = peripheral.find("speaker")
  3277.     if not fs.exists(DIR) then fs.makeDir(DIR) end
  3278.     local scroll = 0
  3279.     while true do
  3280.         local files = songList()
  3281.         local streams = ctx.load("streams", {})
  3282.         local entries = {}
  3283.         for _, f in ipairs(files) do
  3284.             entries[#entries + 1] = { file = f }
  3285.         end
  3286.         for i, s in ipairs(streams) do
  3287.             entries[#entries + 1] = { stream = i, name = s.name }
  3288.         end
  3289.  
  3290.         ui.clear(colors.white)
  3291.         ui.topBar("Music", "+", colors.blue)
  3292.         local rows = {}
  3293.         local y = 3
  3294.         local searchRow
  3295.         if serverURL() then
  3296.             ui.text(2, y, "\7", colors.green, colors.white)
  3297.             ui.text(4, y, "Search online music", colors.blue, colors.white)
  3298.             searchRow = y
  3299.             y = y + 2
  3300.         end
  3301.         for i = scroll + 1, #entries do
  3302.             if y > ui.h - 2 then break end
  3303.             local en = entries[i]
  3304.             if en.file then
  3305.                 ui.text(2, y, "\14", isMidi(en.file) and colors.blue or colors.red, colors.white)
  3306.                 ui.text(4, y, stripExt(en.file):sub(1, ui.w - 4), colors.black, colors.white)
  3307.             else
  3308.                 ui.text(2, y, "\16", colors.green, colors.white)
  3309.                 ui.text(4, y, (en.name or "Stream"):sub(1, ui.w - 4), colors.black, colors.white)
  3310.             end
  3311.             rows[#rows + 1] = { y = y, entry = en }
  3312.             y = y + 2
  3313.         end
  3314.         if #entries == 0 then
  3315.             ui.center(8, "No songs yet", colors.gray, colors.white)
  3316.             ui.center(10, "Drag .nbs or .mid files", colors.gray, colors.white)
  3317.             ui.center(11, "onto the game window,", colors.gray, colors.white)
  3318.             ui.center(12, "or tap + for a URL", colors.gray, colors.white)
  3319.         end
  3320.         if speaker then
  3321.             ui.center(ui.h, "Drop songs to import", colors.lightGray, colors.white)
  3322.         else
  3323.             ui.center(ui.h, "No speaker!", colors.red, colors.white)
  3324.         end
  3325.  
  3326.         local e, a, b, c2 = os.pullEvent()
  3327.         if e == "mouse_click" then
  3328.             if ui.isBack(b, c2) then return end
  3329.             if c2 == 1 and b >= ui.w - 3 then
  3330.                 import()
  3331.             elseif searchRow and c2 == searchRow then
  3332.                 searchOnline()
  3333.             else
  3334.                 for _, r in ipairs(rows) do
  3335.                     if c2 == r.y then
  3336.                         if r.entry.file then
  3337.                             nowPlaying(r.entry.file)
  3338.                         else
  3339.                             local s = streams[r.entry.stream]
  3340.                             if s and s.online then
  3341.                                 streamOnline(s.name, "", s.url, s.duration)
  3342.                             else
  3343.                                 streamScreen(streams, r.entry.stream)
  3344.                             end
  3345.                         end
  3346.                         break
  3347.                     end
  3348.                 end
  3349.             end
  3350.         elseif e == "mouse_scroll" then
  3351.             scroll = math.max(0, math.min(scroll + a, math.max(0, #entries - 1)))
  3352.         elseif e == "file_transfer" then
  3353.             local saved = ctx.importFiles(a)
  3354.             local msg = saved > 0
  3355.                 and ("Imported " .. saved .. " song" .. (saved == 1 and "" or "s"))
  3356.                 or "Only .nbs/.mid works"
  3357.             ui.text(math.floor((ui.w - #msg - 2) / 2) + 1, ui.h - 2,
  3358.                 " " .. msg .. " ", colors.white,
  3359.                 saved > 0 and colors.green or colors.gray)
  3360.             sleep(1.2)
  3361.         end
  3362.     end
  3363. end
  3364.  
  3365. return M
  3366. ]==],
  3367. ["pocketos/apps/web.lua"] = [==[
  3368. -- PocketOS app: Web - search the real internet.
  3369. -- Uses Wikipedia's public API over CC's http, since it returns clean
  3370. -- JSON a pocket computer can actually render (Google blocks bots).
  3371.  
  3372. local M = {}
  3373. local ui
  3374.  
  3375. local API = "https://en.wikipedia.org/w/api.php"
  3376.  
  3377. local function jsonGet(url)
  3378.     if not http then return nil, "HTTP is disabled" end
  3379.     local ok, res = pcall(http.get, url)
  3380.     if not ok or not res then return nil, "No connection" end
  3381.     local body = res.readAll()
  3382.     res.close()
  3383.     local decode = textutils.unserialiseJSON or textutils.unserializeJSON
  3384.     local data = decode and decode(body)
  3385.     if not data then return nil, "Bad response" end
  3386.     return data
  3387. end
  3388.  
  3389. local function clean(s)
  3390.     s = tostring(s or "")
  3391.     s = s:gsub("<[^>]->", "")
  3392.     s = s:gsub("&quot;", "\""):gsub("&#39;", "'"):gsub("&amp;", "&")
  3393.     s = s:gsub("&lt;", "<"):gsub("&gt;", ">"):gsub("&nbsp;", " ")
  3394.     return s
  3395. end
  3396.  
  3397. local function readArticle(title)
  3398.     ui.clear(colors.white)
  3399.     ui.topBar(title:sub(1, 14))
  3400.     ui.center(10, "Loading...", colors.gray, colors.white)
  3401.     local data, err = jsonGet(API
  3402.         .. "?action=query&prop=extracts&explaintext=1&exchars=6000"
  3403.         .. "&redirects=1&format=json&formatversion=2&titles="
  3404.         .. textutils.urlEncode(title))
  3405.     local text
  3406.     if data then
  3407.         local pages = data.query and data.query.pages
  3408.         local pg = pages and pages[1]
  3409.         text = pg and pg.extract
  3410.     end
  3411.     if not text or text == "" then
  3412.         ui.center(12, err or "Nothing found", colors.red, colors.white)
  3413.         sleep(1.2)
  3414.         return
  3415.     end
  3416.  
  3417.     local lines = {}
  3418.     for para in (text .. "\n"):gmatch("(.-)\n") do
  3419.         local h = para:match("^=+%s*(.-)%s*=+$")
  3420.         if h then
  3421.             lines[#lines + 1] = ""
  3422.             lines[#lines + 1] = "\17 " .. h -- heading marker
  3423.         elseif para == "" then
  3424.             lines[#lines + 1] = ""
  3425.         else
  3426.             for _, l in ipairs(ui.wrap(para, ui.w - 2)) do
  3427.                 lines[#lines + 1] = l
  3428.             end
  3429.         end
  3430.     end
  3431.  
  3432.     local view = ui.h - 3
  3433.     local scroll = 0
  3434.     while true do
  3435.         ui.clear(colors.white)
  3436.         ui.topBar(title:sub(1, 14))
  3437.         local y = 3
  3438.         for i = scroll + 1, #lines do
  3439.             if y > ui.h - 1 then break end
  3440.             local l = lines[i]
  3441.             local h = l:match("^\17 (.*)")
  3442.             if h then
  3443.                 ui.text(2, y, h:sub(1, ui.w - 2), colors.blue, colors.white)
  3444.             elseif l ~= "" then
  3445.                 ui.text(2, y, l, colors.black, colors.white)
  3446.             end
  3447.             y = y + 1
  3448.         end
  3449.         ui.center(ui.h, "scroll for more", colors.lightGray, colors.white)
  3450.  
  3451.         local e, a, b, c = os.pullEvent()
  3452.         if e == "mouse_click" and ui.isBack(b, c) then return end
  3453.         if e == "mouse_scroll" then
  3454.             scroll = math.max(0, math.min(scroll + a * 3, math.max(0, #lines - view)))
  3455.         elseif e == "key" then
  3456.             if a == keys.down then
  3457.                 scroll = math.min(scroll + 3, math.max(0, #lines - view))
  3458.             elseif a == keys.up then
  3459.                 scroll = math.max(0, scroll - 3)
  3460.             end
  3461.         end
  3462.     end
  3463. end
  3464.  
  3465. local function results(q)
  3466.     ui.clear(colors.white)
  3467.     ui.topBar("Results")
  3468.     ui.center(10, "Searching...", colors.gray, colors.white)
  3469.     local data, err = jsonGet(API
  3470.         .. "?action=query&list=search&srlimit=8&format=json&formatversion=2"
  3471.         .. "&srsearch=" .. textutils.urlEncode(q))
  3472.     local hits = data and data.query and data.query.search
  3473.     if not hits or #hits == 0 then
  3474.         ui.fill(1, 10, ui.w, 1, colors.white)
  3475.         ui.center(10, err or "No results", colors.red, colors.white)
  3476.         sleep(1.2)
  3477.         return
  3478.     end
  3479.  
  3480.     local scroll = 0
  3481.     while true do
  3482.         ui.clear(colors.white)
  3483.         ui.topBar("Results")
  3484.         local rows = {}
  3485.         local y = 3
  3486.         for i = scroll + 1, #hits do
  3487.             if y + 1 > ui.h - 1 then break end
  3488.             local it = hits[i]
  3489.             ui.text(2, y, clean(it.title):sub(1, ui.w - 2), colors.blue, colors.white)
  3490.             ui.text(2, y + 1, clean(it.snippet):sub(1, ui.w - 2), colors.gray, colors.white)
  3491.             rows[#rows + 1] = { y1 = y, y2 = y + 1, title = it.title }
  3492.             y = y + 3
  3493.         end
  3494.  
  3495.         local e, a, b, c = os.pullEvent()
  3496.         if e == "mouse_click" then
  3497.             if ui.isBack(b, c) then return end
  3498.             for _, r in ipairs(rows) do
  3499.                 if c >= r.y1 and c <= r.y2 then
  3500.                     readArticle(r.title)
  3501.                     break
  3502.                 end
  3503.             end
  3504.         elseif e == "mouse_scroll" then
  3505.             scroll = math.max(0, math.min(scroll + a, math.max(0, #hits - 1)))
  3506.         end
  3507.     end
  3508. end
  3509.  
  3510. function M.run(u)
  3511.     ui = u
  3512.     while true do
  3513.         ui.clear(colors.white)
  3514.         ui.topBar("Web")
  3515.         local logo = {
  3516.             { "W", colors.blue }, { "e", colors.red }, { "b", colors.yellow },
  3517.         }
  3518.         local lx = math.floor((ui.w - 3) / 2) + 1
  3519.         for i, l in ipairs(logo) do
  3520.             ui.text(lx + i - 1, 5, l[1], l[2], colors.white)
  3521.         end
  3522.         ui.center(8, "Search the internet", colors.gray, colors.white)
  3523.         ui.fill(3, 10, ui.w - 4, 1, colors.lightGray)
  3524.         ui.text(4, 10, "Search...", colors.gray, colors.lightGray)
  3525.         ui.center(ui.h - 1, "Powered by Wikipedia", colors.lightGray, colors.white)
  3526.  
  3527.         local e, a, b, c = os.pullEvent()
  3528.         if e == "mouse_click" then
  3529.             if ui.isBack(b, c) then return end
  3530.             if c == 10 and b >= 3 and b <= ui.w - 2 then
  3531.                 ui.fill(3, 10, ui.w - 4, 1, colors.lightGray)
  3532.                 local q = ui.input(4, 10, { width = ui.w - 6 })
  3533.                 if q and q:gsub("%s", "") ~= "" then
  3534.                     results(q)
  3535.                 end
  3536.             end
  3537.         end
  3538.     end
  3539. end
  3540.  
  3541. return M
  3542. ]==],
  3543. ["pocketos/apps/ai.lua"] = [==[
  3544. -- PocketOS app: AI - chat with an AI via the Groq API.
  3545. -- Get a free API key at console.groq.com, then paste it in on the
  3546. -- device (Ctrl+V pastes). The key is stored only on this computer,
  3547. -- in /.pocketos, and survives OS updates.
  3548.  
  3549. local M = {}
  3550. local ui, ctx
  3551.  
  3552. local URL = "https://api.groq.com/openai/v1/chat/completions"
  3553. local SYSTEM = "You are a helpful assistant running on a tiny pocket "
  3554.     .. "computer in Minecraft (ComputerCraft). Answer in short plain "
  3555.     .. "text; the screen is only 26 characters wide."
  3556.  
  3557. local function decode(s)
  3558.     local d = textutils.unserialiseJSON or textutils.unserializeJSON
  3559.     if not d then return nil end
  3560.     local ok, v = pcall(d, s)
  3561.     return ok and v or nil
  3562. end
  3563.  
  3564. local function encode(t)
  3565.     local e = textutils.serialiseJSON or textutils.serializeJSON
  3566.     return e(t)
  3567. end
  3568.  
  3569. local function model()
  3570.     local m = ctx.config and ctx.config.aiModel
  3571.     if type(m) == "string" and m ~= "" then return m end
  3572.     return "llama-3.3-70b-versatile"
  3573. end
  3574.  
  3575. local function masked(key)
  3576.     if #key <= 8 then return "****" end
  3577.     return key:sub(1, 4) .. "..." .. key:sub(-4)
  3578. end
  3579.  
  3580. local function keyScreen()
  3581.     ui.clear(colors.white)
  3582.     ui.topBar("API Key")
  3583.     local key = ctx.load("groq_key", "")
  3584.     ui.text(2, 3, "Groq API key:", colors.gray, colors.white)
  3585.     ui.text(2, 4, key == "" and "Not set" or masked(key),
  3586.         key == "" and colors.red or colors.black, colors.white)
  3587.     ui.text(2, 6, "Get a free key at", colors.gray, colors.white)
  3588.     ui.text(2, 7, "console.groq.com", colors.blue, colors.white)
  3589.     ui.text(2, 9, "Paste it below with", colors.gray, colors.white)
  3590.     ui.text(2, 10, "Ctrl+V, then Enter:", colors.gray, colors.white)
  3591.     ui.fill(2, 12, ui.w - 2, 1, colors.lightGray)
  3592.     ui.text(2, 14, "Enter = keep current,", colors.lightGray, colors.white)
  3593.     ui.text(2, 15, "type 'clear' to remove", colors.lightGray, colors.white)
  3594.     local s = ui.input(3, 12, { width = ui.w - 4 })
  3595.     if s == nil then return end
  3596.     s = s:gsub("%s", "")
  3597.     if s:lower() == "clear" then
  3598.         ctx.save("groq_key", "")
  3599.         ui.center(17, "Key removed", colors.gray, colors.white)
  3600.         sleep(0.8)
  3601.     elseif s ~= "" then
  3602.         ctx.save("groq_key", s)
  3603.         ui.center(17, "Key saved!", colors.green, colors.white)
  3604.         sleep(0.8)
  3605.     end
  3606. end
  3607.  
  3608. local function ask(msgs)
  3609.     local key = ctx.load("groq_key", "")
  3610.     local body = encode({
  3611.         model = model(),
  3612.         messages = msgs,
  3613.         max_tokens = 400,
  3614.         temperature = 0.7,
  3615.     })
  3616.     local ok, res, err, errRes = pcall(http.post, URL, body, {
  3617.         ["Content-Type"] = "application/json",
  3618.         ["Authorization"] = "Bearer " .. key,
  3619.     })
  3620.     if not ok then return nil, "Request failed" end
  3621.     if not res then
  3622.         local detail
  3623.         if errRes then
  3624.             local eb = errRes.readAll()
  3625.             pcall(errRes.close)
  3626.             local data = decode(eb)
  3627.             detail = data and data.error and data.error.message
  3628.         end
  3629.         return nil, detail or err or "No connection"
  3630.     end
  3631.     local data = decode(res.readAll())
  3632.     res.close()
  3633.     local content = data and data.choices and data.choices[1]
  3634.         and data.choices[1].message and data.choices[1].message.content
  3635.     if type(content) ~= "string" or content == "" then
  3636.         local emsg = data and data.error and data.error.message
  3637.         return nil, emsg or "Empty response"
  3638.     end
  3639.     return content
  3640. end
  3641.  
  3642. local function chatLines(chat)
  3643.     local lines = {}
  3644.     for _, m in ipairs(chat) do
  3645.         local wrapped = ui.wrap(m.text, 19)
  3646.         local width = 0
  3647.         for _, s in ipairs(wrapped) do width = math.max(width, #s) end
  3648.         for _, s in ipairs(wrapped) do
  3649.             local pad = " " .. s .. string.rep(" ", width - #s) .. " "
  3650.             if m.role == "user" then
  3651.                 lines[#lines + 1] = { text = pad, x = ui.w - #pad, fg = colors.white, bg = colors.blue }
  3652.             elseif m.role == "error" then
  3653.                 lines[#lines + 1] = { text = pad, x = 2, fg = colors.red, bg = colors.white }
  3654.             else
  3655.                 lines[#lines + 1] = { text = pad, x = 2, fg = colors.black, bg = colors.lightGray }
  3656.             end
  3657.         end
  3658.         lines[#lines + 1] = { spacer = true }
  3659.     end
  3660.     if #lines > 0 then lines[#lines] = nil end
  3661.     return lines
  3662. end
  3663.  
  3664. local function drawChat(chat, scroll, busy)
  3665.     ui.clear(colors.white)
  3666.     ui.topBar("AI", "\15 +", colors.blue)
  3667.     local lines = chatLines(chat)
  3668.     local top, bottom = 2, ui.h - 2
  3669.     local yy = bottom
  3670.     local i = #lines - scroll
  3671.     while i >= 1 and yy >= top do
  3672.         local L = lines[i]
  3673.         if not L.spacer then
  3674.             ui.text(L.x, yy, L.text, L.fg, L.bg)
  3675.         end
  3676.         yy = yy - 1
  3677.         i = i - 1
  3678.     end
  3679.     if #chat == 0 then
  3680.         ui.center(8, "Ask me anything!", colors.gray, colors.white)
  3681.         ui.center(10, "\15 = API key", colors.lightGray, colors.white)
  3682.         ui.center(11, "+ = new chat", colors.lightGray, colors.white)
  3683.     end
  3684.     ui.fill(1, ui.h, ui.w, 1, colors.lightGray)
  3685.     if busy then
  3686.         ui.text(2, ui.h, "Thinking...", colors.gray, colors.lightGray)
  3687.     else
  3688.         ui.text(2, ui.h, "Ask something...", colors.gray, colors.lightGray)
  3689.     end
  3690.     return #lines
  3691. end
  3692.  
  3693. function M.run(u, c)
  3694.     ui, ctx = u, c
  3695.     local chat = ctx.load("ai_chat", {})
  3696.     local scroll = 0
  3697.     if ctx.load("groq_key", "") == "" then keyScreen() end
  3698.     while true do
  3699.         local total = drawChat(chat, scroll, false)
  3700.         local e, b, mx, my = os.pullEvent()
  3701.         if e == "mouse_click" then
  3702.             if ui.isBack(mx, my) then return end
  3703.             if my == 1 and mx >= ui.w - 3 then
  3704.                 if mx >= ui.w - 1 then
  3705.                     chat = {}
  3706.                     ctx.save("ai_chat", chat)
  3707.                     scroll = 0
  3708.                 else
  3709.                     keyScreen()
  3710.                 end
  3711.             elseif my == ui.h then
  3712.                 if not http then
  3713.                     ui.dialog("No HTTP",
  3714.                         { "HTTP is disabled in", "the server config." },
  3715.                         { { "OK", colors.blue } })
  3716.                 elseif ctx.load("groq_key", "") == "" then
  3717.                     keyScreen()
  3718.                 else
  3719.                     ui.fill(1, ui.h, ui.w, 1, colors.white)
  3720.                     local q = ui.input(2, ui.h, { width = ui.w - 2, bg = colors.white })
  3721.                     if q and q:gsub("%s", "") ~= "" then
  3722.                         chat[#chat + 1] = { role = "user", text = q }
  3723.                         scroll = 0
  3724.                         drawChat(chat, scroll, true)
  3725.                         local msgs = { { role = "system", content = SYSTEM } }
  3726.                         for i = math.max(1, #chat - 9), #chat do
  3727.                             local m = chat[i]
  3728.                             if m.role ~= "error" then
  3729.                                 msgs[#msgs + 1] = { role = m.role, content = m.text }
  3730.                             end
  3731.                         end
  3732.                         local reply, err = ask(msgs)
  3733.                         if reply then
  3734.                             reply = reply:gsub("^%s+", ""):gsub("%s+$", ""):sub(1, 1200)
  3735.                             chat[#chat + 1] = { role = "assistant", text = reply }
  3736.                         else
  3737.                             chat[#chat + 1] = { role = "error", text = "! " .. tostring(err):sub(1, 150) }
  3738.                         end
  3739.                         while #chat > 40 do table.remove(chat, 1) end
  3740.                         ctx.save("ai_chat", chat)
  3741.                     end
  3742.                 end
  3743.             end
  3744.         elseif e == "mouse_scroll" then
  3745.             if b > 0 then
  3746.                 scroll = math.max(0, scroll - 3)
  3747.             else
  3748.                 scroll = math.min(math.max(0, total - 1), scroll + 3)
  3749.             end
  3750.         end
  3751.     end
  3752. end
  3753.  
  3754. return M
  3755. ]==],
  3756. ["pocketos/apps/bank.lua"] = [==[
  3757. -- PocketOS app: Bank - your wallet.
  3758. -- Shows your balance from the bank server and lets you pay other
  3759. -- pockets by ID. Deposits happen at ATMs (see atm.lua); receipts
  3760. -- arrive in Messages. Your account is this pocket's computer ID.
  3761.  
  3762. local M = {}
  3763. local ui, ctx
  3764.  
  3765. local bankId
  3766.  
  3767. local function findBank()
  3768.     if ctx.config and tonumber(ctx.config.bank) then
  3769.         return tonumber(ctx.config.bank)
  3770.     end
  3771.     return rednet.lookup("pocketos.bank")
  3772. end
  3773.  
  3774. local function request(msg)
  3775.     if not ctx.modem then return nil, "No wireless modem" end
  3776.     if not bankId then bankId = findBank() end
  3777.     if not bankId then return nil, "No bank in range" end
  3778.     msg.label = os.getComputerLabel()
  3779.     rednet.send(bankId, msg, "pocketos.bank")
  3780.     local deadline = os.clock() + 3
  3781.     while true do
  3782.         local left = deadline - os.clock()
  3783.         if left <= 0 then return nil, "Bank not answering" end
  3784.         local id, resp = rednet.receive("pocketos.bank", left)
  3785.         if id == bankId and type(resp) == "table" then
  3786.             return resp
  3787.         elseif id == nil then
  3788.             return nil, "Bank not answering"
  3789.         end
  3790.     end
  3791. end
  3792.  
  3793. local function payFlow()
  3794.     ui.clear(colors.white)
  3795.     ui.topBar("Pay")
  3796.     ui.text(2, 3, "Pay to pocket ID:", colors.gray, colors.white)
  3797.     ui.fill(2, 5, ui.w - 2, 1, colors.lightGray)
  3798.     local to = ui.input(3, 5, { width = ui.w - 4 })
  3799.     if to == nil then return end
  3800.     to = tonumber(to)
  3801.     if not to then return end
  3802.     ui.text(2, 7, "Amount ($):", colors.gray, colors.white)
  3803.     ui.fill(2, 9, ui.w - 2, 1, colors.lightGray)
  3804.     local amount = ui.input(3, 9, { width = ui.w - 4 })
  3805.     if amount == nil then return end
  3806.     amount = tonumber(amount)
  3807.     if not amount or amount <= 0 then return end
  3808.     amount = math.floor(amount)
  3809.     local pick = ui.dialog("Confirm",
  3810.         { "Pay $" .. amount, "to #" .. to .. "?" },
  3811.         { { "Pay", colors.green }, { "Cancel", colors.gray } })
  3812.     if pick ~= 1 then return end
  3813.     ui.center(12, "Sending...", colors.gray, colors.white)
  3814.     local resp, err = request({ type = "pay", to = to, amount = amount })
  3815.     if resp and resp.ok then
  3816.         ui.dialog("Sent!", { "Paid $" .. amount, "to #" .. to },
  3817.             { { "OK", colors.blue } })
  3818.     else
  3819.         ui.dialog("Failed",
  3820.             { tostring((resp and resp.err) or err or "Unknown error"):sub(1, 18) },
  3821.             { { "OK", colors.blue } })
  3822.     end
  3823. end
  3824.  
  3825. function M.run(u, c)
  3826.     ui, ctx = u, c
  3827.     local balance, err
  3828.  
  3829.     local function refresh()
  3830.         ui.clear(colors.white)
  3831.         ui.topBar("Bank")
  3832.         ui.center(9, "Talking to bank...", colors.gray, colors.white)
  3833.         local resp
  3834.         resp, err = request({ type = "balance" })
  3835.         if resp and resp.ok then
  3836.             balance = resp.balance
  3837.             err = nil
  3838.         elseif resp then
  3839.             err = resp.err or "Bank error"
  3840.         end
  3841.     end
  3842.  
  3843.     refresh()
  3844.     while true do
  3845.         local payBtn, retryBtn
  3846.         ui.clear(colors.white)
  3847.         ui.topBar("Bank")
  3848.         if err then
  3849.             ui.center(6, "Can't reach the bank", colors.red, colors.white)
  3850.             ui.center(8, tostring(err):sub(1, 24), colors.gray, colors.white)
  3851.             ui.center(11, "Needs a wireless modem", colors.lightGray, colors.white)
  3852.             ui.center(12, "+ a bank server in range", colors.lightGray, colors.white)
  3853.             retryBtn = ui.button(math.floor((ui.w - 7) / 2) + 1, 15, "Retry", colors.blue)
  3854.         else
  3855.             -- balance card, credit-card style
  3856.             ui.card(3, 4, ui.w - 6, 5, colors.blue)
  3857.             ui.text(5, 5, "Your balance", colors.lightGray, colors.blue)
  3858.             ui.text(5, 7, "$ " .. tostring(balance or 0), colors.white, colors.blue)
  3859.             payBtn = ui.button(math.floor((ui.w - 13) / 2) + 1, 11, "Pay someone", colors.green)
  3860.             ui.center(14, "Deposit iron, gold or", colors.lightGray, colors.white)
  3861.             ui.center(15, "diamonds at any ATM", colors.lightGray, colors.white)
  3862.             ui.center(17, "Tap balance to refresh", colors.lightGray, colors.white)
  3863.             ui.center(ui.h, "My ID: " .. os.getComputerID(), colors.gray, colors.white)
  3864.         end
  3865.  
  3866.         local e, a, b, c2 = os.pullEvent()
  3867.         if e == "mouse_click" then
  3868.             if ui.isBack(b, c2) then return end
  3869.             if err then
  3870.                 if ui.hit(retryBtn, b, c2) then
  3871.                     bankId = nil
  3872.                     refresh()
  3873.                 end
  3874.             elseif ui.hit(payBtn, b, c2) then
  3875.                 payFlow()
  3876.                 refresh()
  3877.             elseif c2 >= 4 and c2 <= 8 then
  3878.                 refresh()
  3879.             end
  3880.         elseif e == "pocketos_message" then
  3881.             -- a bank receipt probably just arrived; update the number
  3882.             refresh()
  3883.         end
  3884.     end
  3885. end
  3886.  
  3887. return M
  3888. ]==],
  3889. ["pocketos/apps/market.lua"] = [==[
  3890. -- PocketOS app: Market - buy and sell with other players.
  3891. -- Listings live on the bank server. No shipping, this is Minecraft:
  3892. -- message the seller through Messages to arrange the handover, and
  3893. -- pay them from your bank balance when you're ready - paying is always
  3894. -- a manual, confirmed step.
  3895.  
  3896. local M = {}
  3897. local ui, ctx
  3898.  
  3899. local bankId
  3900.  
  3901. local function findBank()
  3902.     if ctx.config and tonumber(ctx.config.bank) then
  3903.         return tonumber(ctx.config.bank)
  3904.     end
  3905.     return rednet.lookup("pocketos.bank")
  3906. end
  3907.  
  3908. local function request(msg)
  3909.     if not ctx.modem then return nil, "No wireless modem" end
  3910.     if not bankId then bankId = findBank() end
  3911.     if not bankId then return nil, "No bank in range" end
  3912.     msg.label = os.getComputerLabel()
  3913.     rednet.send(bankId, msg, "pocketos.bank")
  3914.     local deadline = os.clock() + 3
  3915.     while true do
  3916.         local left = deadline - os.clock()
  3917.         if left <= 0 then return nil, "Bank not answering" end
  3918.         local id, resp = rednet.receive("pocketos.bank", left)
  3919.         if id == bankId and type(resp) == "table" then
  3920.             return resp
  3921.         elseif id == nil then
  3922.             return nil, "Bank not answering"
  3923.         end
  3924.     end
  3925. end
  3926.  
  3927. -- drops a note into the other player's Messages app
  3928. local function sendNote(to, text)
  3929.     rednet.send(tonumber(to),
  3930.         { text = text, label = os.getComputerLabel() }, "pocketos")
  3931. end
  3932.  
  3933. local function createFlow()
  3934.     ui.clear(colors.white)
  3935.     ui.topBar("New Listing")
  3936.     ui.text(2, 3, "Item name:", colors.gray, colors.white)
  3937.     ui.fill(2, 4, ui.w - 2, 1, colors.lightGray)
  3938.     local name = ui.input(3, 4, { width = ui.w - 4 })
  3939.     if name == nil then return end
  3940.     name = name:gsub("^%s+", ""):gsub("%s+$", ""):sub(1, 20)
  3941.     if name == "" then return end
  3942.     ui.text(2, 6, "Price ($):", colors.gray, colors.white)
  3943.     ui.fill(2, 7, ui.w - 2, 1, colors.lightGray)
  3944.     local price = ui.input(3, 7, { width = ui.w - 4 })
  3945.     if price == nil then return end
  3946.     price = tonumber(price)
  3947.     if not price or price <= 0 then
  3948.         ui.center(12, "Price must be a number", colors.red, colors.white)
  3949.         sleep(1.2)
  3950.         return
  3951.     end
  3952.     price = math.floor(price)
  3953.     ui.text(2, 9, "Details (optional):", colors.gray, colors.white)
  3954.     ui.fill(2, 10, ui.w - 2, 1, colors.lightGray)
  3955.     local desc = ui.input(3, 10, { width = ui.w - 4 })
  3956.     if desc == nil then return end
  3957.     desc = desc:sub(1, 60)
  3958.     local pick = ui.dialog("Post It?", { name:sub(1, 18), "$" .. price },
  3959.         { { "Post", colors.green }, { "Cancel", colors.gray } })
  3960.     if pick ~= 1 then return end
  3961.     local resp, err = request({ type = "mkpost", name = name, price = price, desc = desc })
  3962.     if resp and resp.ok then
  3963.         ui.center(13, "Listed!", colors.green, colors.white)
  3964.     else
  3965.         ui.center(13, tostring((resp and resp.err) or err or "Failed"):sub(1, 24),
  3966.             colors.red, colors.white)
  3967.     end
  3968.     sleep(1)
  3969. end
  3970.  
  3971. local function detail(it)
  3972.     local mine = it.seller == os.getComputerID()
  3973.     while true do
  3974.         ui.clear(colors.white)
  3975.         ui.topBar(tostring(it.name):sub(1, 14))
  3976.         ui.center(3, "$ " .. it.price, colors.green, colors.white)
  3977.         ui.center(5, ("Sold by " .. (it.label and (it.label .. " ") or "")
  3978.             .. "#" .. it.seller):sub(1, 24), colors.gray, colors.white)
  3979.         local y = 7
  3980.         if it.desc and it.desc ~= "" then
  3981.             for _, l in ipairs(ui.wrap(it.desc, ui.w - 4)) do
  3982.                 if y > 12 then break end
  3983.                 ui.text(3, y, l, colors.black, colors.white)
  3984.                 y = y + 1
  3985.             end
  3986.         end
  3987.         local payBtn, msgBtn, rmBtn
  3988.         if mine then
  3989.             rmBtn = ui.button(math.floor((ui.w - 16) / 2) + 1, 15, "Remove listing", colors.red)
  3990.             ui.center(18, "This one's yours", colors.lightGray, colors.white)
  3991.         else
  3992.             payBtn = ui.button(3, 15, "Pay $" .. it.price, colors.green)
  3993.             msgBtn = ui.button(ui.w - 6, 15, "Msg", colors.blue)
  3994.             ui.center(18, "Msg them to arrange it", colors.lightGray, colors.white)
  3995.         end
  3996.  
  3997.         local e, a, b, c = os.pullEvent("mouse_click")
  3998.         if ui.isBack(b, c) then return end
  3999.         if mine and ui.hit(rmBtn, b, c) then
  4000.             local pick = ui.dialog("Remove?", { tostring(it.name):sub(1, 18) },
  4001.                 { { "Remove", colors.red }, { "Keep", colors.gray } })
  4002.             if pick == 1 then
  4003.                 request({ type = "mkremove", id = it.id })
  4004.                 return
  4005.             end
  4006.         elseif not mine and ui.hit(payBtn, b, c) then
  4007.             local pick = ui.dialog("Confirm",
  4008.                 { "Pay $" .. it.price .. " to", "#" .. it.seller .. " for this?" },
  4009.                 { { "Pay", colors.green }, { "Cancel", colors.gray } })
  4010.             if pick == 1 then
  4011.                 local resp, err = request({ type = "pay", to = it.seller, amount = it.price })
  4012.                 if resp and resp.ok then
  4013.                     sendNote(it.seller, "[Market] Paid $" .. it.price .. " for: " .. it.name)
  4014.                     ui.dialog("Paid!", { "Now message them to", "arrange the handover." },
  4015.                         { { "OK", colors.blue } })
  4016.                 else
  4017.                     ui.dialog("Failed",
  4018.                         { tostring((resp and resp.err) or err or "?"):sub(1, 18) },
  4019.                         { { "OK", colors.blue } })
  4020.                 end
  4021.             end
  4022.         elseif not mine and ui.hit(msgBtn, b, c) then
  4023.             sendNote(it.seller, "[Market] Interested in: " .. it.name .. " ($" .. it.price .. ")")
  4024.             ui.dialog("Sent!", { "Check Messages for", "their reply." },
  4025.                 { { "OK", colors.blue } })
  4026.         end
  4027.     end
  4028. end
  4029.  
  4030. function M.run(u, c)
  4031.     ui, ctx = u, c
  4032.     local items, err
  4033.     local scroll = 0
  4034.  
  4035.     local function fetch()
  4036.         ui.clear(colors.white)
  4037.         ui.topBar("Market")
  4038.         ui.center(9, "Loading...", colors.gray, colors.white)
  4039.         local resp
  4040.         resp, err = request({ type = "mklist" })
  4041.         if resp and resp.ok and type(resp.items) == "table" then
  4042.             items = resp.items
  4043.             err = nil
  4044.         else
  4045.             items = nil
  4046.             err = (resp and resp.err) or err or "Bank offline"
  4047.         end
  4048.         scroll = 0
  4049.     end
  4050.  
  4051.     fetch()
  4052.     while true do
  4053.         local retryBtn
  4054.         ui.clear(colors.white)
  4055.         ui.topBar("Market", "+", colors.blue)
  4056.         local rows = {}
  4057.         if err then
  4058.             ui.center(6, "Can't reach the market", colors.red, colors.white)
  4059.             ui.center(8, tostring(err):sub(1, 24), colors.gray, colors.white)
  4060.             retryBtn = ui.button(math.floor((ui.w - 7) / 2) + 1, 12, "Retry", colors.blue)
  4061.         elseif #items == 0 then
  4062.             ui.center(8, "Nothing for sale yet", colors.gray, colors.white)
  4063.             ui.center(10, "Tap + to list an item", colors.gray, colors.white)
  4064.         else
  4065.             local y = 3
  4066.             for i = scroll + 1, #items do
  4067.                 if y + 1 > ui.h - 1 then break end
  4068.                 local it = items[i]
  4069.                 ui.text(2, y, tostring(it.name):sub(1, 17), colors.black, colors.white)
  4070.                 local p = "$" .. it.price
  4071.                 ui.text(ui.w - #p, y, p, colors.green, colors.white)
  4072.                 local who
  4073.                 if it.seller == os.getComputerID() then
  4074.                     who = "your listing"
  4075.                 else
  4076.                     who = "by " .. (it.label and (it.label .. " ") or "") .. "#" .. it.seller
  4077.                 end
  4078.                 ui.text(2, y + 1, who:sub(1, ui.w - 2), colors.gray, colors.white)
  4079.                 rows[#rows + 1] = { y1 = y, y2 = y + 1, it = it }
  4080.                 y = y + 3
  4081.             end
  4082.         end
  4083.  
  4084.         local e, a, b, c2 = os.pullEvent()
  4085.         if e == "mouse_click" then
  4086.             if ui.isBack(b, c2) then return end
  4087.             if c2 == 1 and b >= ui.w - 3 then
  4088.                 createFlow()
  4089.                 fetch()
  4090.             elseif err and ui.hit(retryBtn, b, c2) then
  4091.                 bankId = nil
  4092.                 fetch()
  4093.             else
  4094.                 for _, r in ipairs(rows) do
  4095.                     if c2 >= r.y1 and c2 <= r.y2 then
  4096.                         detail(r.it)
  4097.                         fetch()
  4098.                         break
  4099.                     end
  4100.                 end
  4101.             end
  4102.         elseif e == "mouse_scroll" and items then
  4103.             scroll = math.max(0, math.min(scroll + a, math.max(0, #items - 1)))
  4104.         end
  4105.     end
  4106. end
  4107.  
  4108. return M
  4109. ]==],
  4110. ["pocketos/apps/maps.lua"] = [==[
  4111. -- PocketOS app: Maps - save waypoints and navigate back to them.
  4112. -- A compass needle points at the selected place. Walk a few blocks and
  4113. -- the needle rotates to your direction of travel (up = straight ahead,
  4114. -- N marker = true north); standing still it points with north up.
  4115. -- Waypoints are saved in /.pocketos and survive OS updates.
  4116.  
  4117. local M = {}
  4118. local ui, ctx
  4119.  
  4120. local atan2 = math.atan2 or function(y, x) return math.atan(y, x) end
  4121.  
  4122. local function flatDist(a, b)
  4123.     local dx, dz = b.x - a.x, b.z - a.z
  4124.     return math.sqrt(dx * dx + dz * dz)
  4125. end
  4126.  
  4127. local function fmtDist(d)
  4128.     if d >= 1000 then
  4129.         return string.format("%.1fkm", d / 1000)
  4130.     end
  4131.     return math.floor(d + 0.5) .. "m"
  4132. end
  4133.  
  4134. local function loadList()
  4135.     return ctx.load("waypoints", {})
  4136. end
  4137.  
  4138. local function saveList(list)
  4139.     ctx.save("waypoints", list)
  4140. end
  4141.  
  4142. local function inputAt(y, label, default)
  4143.     ui.text(2, y, label, colors.gray, colors.white)
  4144.     ui.fill(8, y, ui.w - 8, 1, colors.lightGray)
  4145.     return ui.input(9, y, { default = default or "", width = ui.w - 9 })
  4146. end
  4147.  
  4148. local function addPlace(list)
  4149.     ui.clear(colors.white)
  4150.     ui.topBar("Add Place")
  4151.     local fix = ctx.fix
  4152.     if fix then
  4153.         ui.text(2, 3, "Prefilled with where", colors.lightGray, colors.white)
  4154.         ui.text(2, 4, "you are right now.", colors.lightGray, colors.white)
  4155.     end
  4156.     local name = inputAt(6, "Name:", "")
  4157.     if name == nil then return end
  4158.     name = name:gsub("^%s+", ""):gsub("%s+$", ""):sub(1, 12)
  4159.     if name == "" then return end
  4160.     local xs = inputAt(8, "X:", fix and tostring(fix.x) or "")
  4161.     if xs == nil then return end
  4162.     local ys = inputAt(10, "Y:", fix and tostring(fix.y) or "")
  4163.     if ys == nil then return end
  4164.     local zs = inputAt(12, "Z:", fix and tostring(fix.z) or "")
  4165.     if zs == nil then return end
  4166.     local x, y, z = tonumber(xs), tonumber(ys), tonumber(zs)
  4167.     if not (x and y and z) then
  4168.         ui.center(15, "Coords must be numbers", colors.red, colors.white)
  4169.         sleep(1.4)
  4170.         return
  4171.     end
  4172.     list[#list + 1] = {
  4173.         name = name,
  4174.         x = math.floor(x),
  4175.         y = math.floor(y),
  4176.         z = math.floor(z),
  4177.     }
  4178.     saveList(list)
  4179.     ui.center(15, "Saved " .. name .. "!", colors.green, colors.white)
  4180.     sleep(0.8)
  4181. end
  4182.  
  4183. local function drawCompass(rel, heading)
  4184.     local cx, cy = math.floor(ui.w / 2) + 1, 11
  4185.     local rx, ry = 8, 5
  4186.     -- ring
  4187.     for i = 0, 23 do
  4188.         local a = i * math.pi / 12
  4189.         local px = cx + math.floor(math.sin(a) * rx + 0.5)
  4190.         local py = cy - math.floor(math.cos(a) * ry + 0.5)
  4191.         ui.text(px, py, "\7", colors.lightGray, colors.white)
  4192.     end
  4193.     -- north marker (rotates when the needle is heading-relative)
  4194.     local an = -(heading or 0)
  4195.     ui.text(cx + math.floor(math.sin(an) * rx + 0.5),
  4196.         cy - math.floor(math.cos(an) * ry + 0.5), "N", colors.gray, colors.white)
  4197.     -- needle
  4198.     for _, f in ipairs({ 0.45, 0.75 }) do
  4199.         local px = cx + math.floor(math.sin(rel) * rx * f + 0.5)
  4200.         local py = cy - math.floor(math.cos(rel) * ry * f + 0.5)
  4201.         ui.text(px, py, "\7", colors.blue, colors.white)
  4202.     end
  4203.     local tx = cx + math.floor(math.sin(rel) * rx + 0.5)
  4204.     local ty = cy - math.floor(math.cos(rel) * ry + 0.5)
  4205.     ui.text(tx, ty, "\4", colors.red, colors.white)
  4206.     ui.text(cx, cy, "\7", colors.black, colors.white)
  4207. end
  4208.  
  4209. local function navigate(list, idx)
  4210.     local wp = list[idx]
  4211.     local heading = nil
  4212.     local prev = ctx.fix
  4213.     os.queueEvent("pocketos_locate")
  4214.     local timer = os.startTimer(0.5)
  4215.     while true do
  4216.         local fix = ctx.fix
  4217.         if fix and not prev then
  4218.             prev = fix
  4219.         elseif fix and prev then
  4220.             local mdx, mdz = fix.x - prev.x, fix.z - prev.z
  4221.             if mdx * mdx + mdz * mdz >= 4 then
  4222.                 heading = atan2(mdx, -mdz)
  4223.                 prev = fix
  4224.             end
  4225.         end
  4226.  
  4227.         ui.clear(colors.white)
  4228.         ui.topBar(wp.name:sub(1, 12), "Del", colors.red)
  4229.         ui.center(2, wp.x .. ", " .. wp.y .. ", " .. wp.z, colors.lightGray, colors.white)
  4230.         if not fix then
  4231.             ui.center(9, "No GPS signal", colors.red, colors.white)
  4232.             ui.center(11, "Waiting for a fix...", colors.gray, colors.white)
  4233.         else
  4234.             local dx, dz = wp.x - fix.x, wp.z - fix.z
  4235.             local d = math.sqrt(dx * dx + dz * dz)
  4236.             if d < 3 then
  4237.                 ui.center(9, "\4 You're here! \4", colors.green, colors.white)
  4238.                 ui.center(11, "(within 3 blocks)", colors.gray, colors.white)
  4239.                 local dy = wp.y - fix.y
  4240.                 if math.abs(dy) >= 2 then
  4241.                     ui.center(13, (dy > 0 and "\24 " or "\25 ") .. math.abs(dy) .. " blocks",
  4242.                         colors.gray, colors.white)
  4243.                 end
  4244.             else
  4245.                 local bearing = atan2(dx, -dz)
  4246.                 drawCompass(bearing - (heading or 0), heading)
  4247.                 ui.center(17, "Distance " .. fmtDist(d), colors.black, colors.white)
  4248.                 local dy = wp.y - fix.y
  4249.                 if math.abs(dy) >= 2 then
  4250.                     ui.center(18, (dy > 0 and "\24 up " or "\25 down ") .. math.abs(dy),
  4251.                         colors.gray, colors.white)
  4252.                 end
  4253.                 if heading then
  4254.                     ui.center(19, "Arrow = your direction", colors.lightGray, colors.white)
  4255.                 else
  4256.                     ui.center(19, "North up - walk to aim", colors.lightGray, colors.white)
  4257.                 end
  4258.             end
  4259.         end
  4260.  
  4261.         local e, a, b, c = os.pullEvent()
  4262.         if e == "timer" and a == timer then
  4263.             os.queueEvent("pocketos_locate")
  4264.             timer = os.startTimer(0.5)
  4265.         elseif e == "mouse_click" then
  4266.             if ui.isBack(b, c) then return end
  4267.             if c == 1 and b >= ui.w - 4 then
  4268.                 local pick = ui.dialog("Delete?", { wp.name },
  4269.                     { { "Delete", colors.red }, { "Keep", colors.gray } })
  4270.                 if pick == 1 then
  4271.                     table.remove(list, idx)
  4272.                     saveList(list)
  4273.                     return
  4274.                 end
  4275.             end
  4276.         end
  4277.         -- "pocketos_fix" events just fall through and redraw
  4278.     end
  4279. end
  4280.  
  4281. function M.run(u, c)
  4282.     ui, ctx = u, c
  4283.     local list = loadList()
  4284.     local scroll = 0
  4285.     os.queueEvent("pocketos_locate")
  4286.     local timer = os.startTimer(2)
  4287.     while true do
  4288.         ui.clear(colors.white)
  4289.         ui.topBar("Maps", "+", colors.blue)
  4290.         local fix = ctx.fix
  4291.         local rows = {}
  4292.         local y = 3
  4293.         for i = scroll + 1, #list do
  4294.             if y > ui.h - 2 then break end
  4295.             local wp = list[i]
  4296.             ui.text(2, y, "\4", colors.blue, colors.white)
  4297.             ui.text(4, y, wp.name:sub(1, 12), colors.black, colors.white)
  4298.             local d = fix and fmtDist(flatDist(fix, wp)) or "-"
  4299.             ui.text(ui.w - #d, y, d, colors.gray, colors.white)
  4300.             rows[#rows + 1] = { y = y, i = i }
  4301.             y = y + 2
  4302.         end
  4303.         if #list == 0 then
  4304.             ui.center(8, "No places saved yet", colors.gray, colors.white)
  4305.             ui.center(10, "Tap + to add one", colors.gray, colors.white)
  4306.         end
  4307.         if fix then
  4308.             ui.center(ui.h, "You: " .. fix.x .. " " .. fix.y .. " " .. fix.z,
  4309.                 colors.lightGray, colors.white)
  4310.         else
  4311.             ui.center(ui.h, "No GPS signal", colors.red, colors.white)
  4312.         end
  4313.  
  4314.         local e, a, b, c2 = os.pullEvent()
  4315.         if e == "timer" and a == timer then
  4316.             os.queueEvent("pocketos_locate")
  4317.             timer = os.startTimer(3)
  4318.         elseif e == "mouse_click" then
  4319.             if ui.isBack(b, c2) then return end
  4320.             if c2 == 1 and b >= ui.w - 3 then
  4321.                 addPlace(list)
  4322.             else
  4323.                 for _, r in ipairs(rows) do
  4324.                     if c2 == r.y then
  4325.                         navigate(list, r.i)
  4326.                         break
  4327.                     end
  4328.                 end
  4329.             end
  4330.         elseif e == "mouse_scroll" then
  4331.             scroll = math.max(0, math.min(scroll + a, math.max(0, #list - 1)))
  4332.         end
  4333.     end
  4334. end
  4335.  
  4336. return M
  4337. ]==],
  4338. ["pocketos/apps/gps.lua"] = [==[
  4339. -- PocketOS app: GPS - shows this device's coordinates.
  4340. -- Positions come from the OS's background locator, which needs a
  4341. -- wireless modem and a GPS host cluster in range (see gpshost.lua).
  4342.  
  4343. local M = {}
  4344.  
  4345. function M.run(ui, ctx)
  4346.     os.queueEvent("pocketos_locate") -- ask for a fresh fix right away
  4347.     local tick = 0
  4348.     local timer = os.startTimer(1)
  4349.     while true do
  4350.         local fix = ctx.fix
  4351.         ui.clear(colors.white)
  4352.         ui.topBar("GPS")
  4353.         if not ctx.modem then
  4354.             ui.center(7, "No wireless modem", colors.red, colors.white)
  4355.             ui.center(9, "GPS needs a wireless", colors.gray, colors.white)
  4356.             ui.center(10, "pocket computer", colors.gray, colors.white)
  4357.         elseif fix then
  4358.             ui.text(6, 5, "X", colors.gray, colors.white)
  4359.             ui.text(10, 5, tostring(fix.x), colors.black, colors.white)
  4360.             ui.text(6, 7, "Y", colors.gray, colors.white)
  4361.             ui.text(10, 7, tostring(fix.y), colors.black, colors.white)
  4362.             ui.text(6, 9, "Z", colors.gray, colors.white)
  4363.             ui.text(10, 9, tostring(fix.z), colors.black, colors.white)
  4364.             local age = math.floor(os.clock() - fix.at)
  4365.             if age <= 7 then
  4366.                 ui.center(12, "\7 Live", colors.green, colors.white)
  4367.             else
  4368.                 ui.center(12, "Updated " .. age .. "s ago", colors.gray, colors.white)
  4369.             end
  4370.         else
  4371.             ui.center(4, "No GPS signal", colors.red, colors.white)
  4372.             ui.center(6, "You need 4 computers", colors.gray, colors.white)
  4373.             ui.center(7, "with wireless modems", colors.gray, colors.white)
  4374.             ui.center(8, "running as gps hosts", colors.gray, colors.white)
  4375.             ui.center(9, "within radio range.", colors.gray, colors.white)
  4376.             ui.center(11, "Run 'gpshost' on each", colors.lightGray, colors.white)
  4377.             ui.center(12, "to set them up.", colors.lightGray, colors.white)
  4378.         end
  4379.  
  4380.         local trk = ctx.config and tonumber(ctx.config.tracker)
  4381.         if trk then
  4382.             if fix then
  4383.                 ui.center(15, "Reporting to #" .. trk, colors.blue, colors.white)
  4384.             else
  4385.                 ui.center(15, "Report to #" .. trk .. ": no fix", colors.orange, colors.white)
  4386.             end
  4387.         else
  4388.             ui.center(15, "Reporting: off", colors.lightGray, colors.white)
  4389.         end
  4390.         ui.center(17, "Tap to refresh", colors.lightGray, colors.white)
  4391.  
  4392.         local e, a, b, c = os.pullEvent()
  4393.         if e == "timer" and a == timer then
  4394.             tick = tick + 1
  4395.             if tick % 2 == 0 then os.queueEvent("pocketos_locate") end
  4396.             timer = os.startTimer(1)
  4397.         elseif e == "mouse_click" then
  4398.             if ui.isBack(b, c) then return end
  4399.             os.queueEvent("pocketos_locate")
  4400.         end
  4401.         -- "pocketos_fix" events just fall through and trigger a redraw
  4402.     end
  4403. end
  4404.  
  4405. return M
  4406. ]==],
  4407. ["pocketos/apps/dev.lua"] = [==[
  4408. -- PocketOS app: Dev - developer tools (visible only in developer mode).
  4409. --  * Remote Shell - run shell commands on other PocketOS devices that
  4410. --    also have developer mode on.
  4411. --  * Fleet - live locations of every device, from the tracker hub.
  4412. --  * Ledger - recent bank transactions.
  4413.  
  4414. local M = {}
  4415. local ui, ctx
  4416.  
  4417. -- generic request/response over a rednet protocol to a known id
  4418. local function ask(to, proto, msg, timeout)
  4419.     if not ctx.modem then return nil, "No wireless modem" end
  4420.     if not to then return nil, "No target" end
  4421.     rednet.send(to, msg, proto)
  4422.     local deadline = os.clock() + (timeout or 3)
  4423.     while true do
  4424.         local left = deadline - os.clock()
  4425.         if left <= 0 then return nil, "No response" end
  4426.         local id, resp = rednet.receive(proto, left)
  4427.         if id == to and type(resp) == "table" then return resp end
  4428.         if id == nil then return nil, "No response" end
  4429.     end
  4430. end
  4431.  
  4432. local function bankId()
  4433.     if ctx.config and tonumber(ctx.config.bank) then return tonumber(ctx.config.bank) end
  4434.     return rednet.lookup("pocketos.bank")
  4435. end
  4436.  
  4437. ------------------------------------------------------ remote shell --
  4438.  
  4439. local function askID(title, prompt)
  4440.     ui.clear(colors.white)
  4441.     ui.topBar(title)
  4442.     ui.text(2, 4, prompt, colors.gray, colors.white)
  4443.     ui.fill(2, 6, ui.w - 2, 1, colors.lightGray)
  4444.     local s = ui.input(3, 6, { default = tostring(ctx.load("dev_last", "") or ""), width = ui.w - 4 })
  4445.     if s == nil then return nil end
  4446.     return tonumber((s:gsub("%s", "")))
  4447. end
  4448.  
  4449. local function remoteShell()
  4450.     local target = askID("Remote Shell", "Target computer ID:")
  4451.     if not target then return end
  4452.     ctx.save("dev_last", target)
  4453.  
  4454.     ui.clear(colors.white)
  4455.     ui.topBar("Shell #" .. target)
  4456.     ui.center(9, "Connecting...", colors.gray, colors.white)
  4457.     local resp, err = ask(target, "pocketos.dev", { type = "ping" })
  4458.     if not resp then
  4459.         ui.dialog("Unreachable",
  4460.             { tostring(err), "Is it on and in range?" }, { { "OK", colors.blue } })
  4461.         return
  4462.     end
  4463.  
  4464.     local history = { "Connected to " .. (resp.label or ("#" .. target))
  4465.         .. " (PocketOS " .. tostring(resp.version or "?") .. ")",
  4466.         "Type a command below. 'exit' quits." }
  4467.     local scroll = 0
  4468.  
  4469.     local function draw()
  4470.         ui.clear(colors.white)
  4471.         ui.topBar("Shell #" .. target)
  4472.         -- flatten history into wrapped lines
  4473.         local lines = {}
  4474.         for _, entry in ipairs(history) do
  4475.             local color = colors.black
  4476.             local text = entry
  4477.             if type(entry) == "table" then color = entry.c; text = entry[1] end
  4478.             for _, l in ipairs(ui.wrap(text, ui.w - 1)) do
  4479.                 lines[#lines + 1] = { l, color }
  4480.             end
  4481.         end
  4482.         local view = ui.h - 3
  4483.         local maxScroll = math.max(0, #lines - view)
  4484.         if scroll > maxScroll then scroll = maxScroll end
  4485.         local first = #lines - view - scroll + 1
  4486.         local y = 3
  4487.         for i = math.max(1, first), #lines do
  4488.             if y > ui.h - 1 then break end
  4489.             if i >= first then
  4490.                 ui.text(2, y, lines[i][1], lines[i][2], colors.white)
  4491.                 y = y + 1
  4492.             end
  4493.         end
  4494.         ui.fill(1, ui.h, ui.w, 1, colors.lightGray)
  4495.         ui.text(2, ui.h, "$", colors.green, colors.lightGray)
  4496.     end
  4497.  
  4498.     while true do
  4499.         draw()
  4500.         local cmd = ui.input(4, ui.h, { width = ui.w - 4 })
  4501.         if cmd == nil then
  4502.             return -- back arrow tapped
  4503.         elseif cmd:gsub("%s", "") == "" then
  4504.             -- ignore empty
  4505.         elseif cmd == "exit" then
  4506.             return
  4507.         else
  4508.             history[#history + 1] = { "$ " .. cmd, colors.blue }
  4509.             scroll = 0
  4510.             draw()
  4511.             ui.text(2, ui.h - 1, "running...", colors.gray, colors.white)
  4512.             local r, e2 = ask(target, "pocketos.dev", { type = "exec", cmd = cmd }, 12)
  4513.             if r and r.ok then
  4514.                 for line in (r.output .. "\n"):gmatch("(.-)\n") do
  4515.                     history[#history + 1] = { line, colors.gray }
  4516.                 end
  4517.             else
  4518.                 history[#history + 1] = { "! " .. tostring(e2 or "failed"), colors.red }
  4519.             end
  4520.             while #history > 200 do table.remove(history, 1) end
  4521.         end
  4522.     end
  4523. end
  4524.  
  4525. ------------------------------------------------------------- fleet --
  4526.  
  4527. local function fleet()
  4528.     local tid = ctx.config and tonumber(ctx.config.tracker)
  4529.     if not tid and ctx.modem then tid = rednet.lookup("pocketos.track") end
  4530.     while true do
  4531.         ui.clear(colors.white)
  4532.         ui.topBar("Fleet")
  4533.         if not tid then
  4534.             ui.center(8, "No tracker set", colors.red, colors.white)
  4535.             ui.center(10, "Set tracker in", colors.gray, colors.white)
  4536.             ui.center(11, "config.lua first", colors.gray, colors.white)
  4537.             local e, _, mx, my = os.pullEvent("mouse_click")
  4538.             if ui.isBack(mx, my) then return end
  4539.         else
  4540.             ui.center(9, "Loading...", colors.gray, colors.white)
  4541.             local resp, err = ask(tid, "pocketos.track", { type = "query" })
  4542.             ui.clear(colors.white)
  4543.             ui.topBar("Fleet", "\21", colors.blue)
  4544.             if not resp or not resp.devices then
  4545.                 ui.center(8, "Tracker offline", colors.red, colors.white)
  4546.                 ui.center(10, tostring(err or ""):sub(1, 24), colors.gray, colors.white)
  4547.             else
  4548.                 local list = resp.devices
  4549.                 table.sort(list, function(a, b) return a.id < b.id end)
  4550.                 ui.text(2, 2, "ID  Name      X/Y/Z", colors.gray, colors.white)
  4551.                 local y = 3
  4552.                 for _, d in ipairs(list) do
  4553.                     if y > ui.h - 1 then break end
  4554.                     ui.text(2, y, "#" .. d.id, colors.black, colors.white)
  4555.                     ui.text(6, y, (d.label or "?"):sub(1, 8), colors.black, colors.white)
  4556.                     local live = d.age <= 30
  4557.                     ui.text(15, y, (d.x .. "," .. d.y .. "," .. d.z):sub(1, ui.w - 15),
  4558.                         live and colors.blue or colors.gray, colors.white)
  4559.                     y = y + 1
  4560.                 end
  4561.                 if #list == 0 then
  4562.                     ui.center(9, "No devices reporting", colors.gray, colors.white)
  4563.                 end
  4564.             end
  4565.             ui.center(ui.h, "Tap refresh, back to exit", colors.lightGray, colors.white)
  4566.             local e, _, mx, my = os.pullEvent("mouse_click")
  4567.             if ui.isBack(mx, my) then return end
  4568.             -- any other tap refreshes
  4569.         end
  4570.     end
  4571. end
  4572.  
  4573. ------------------------------------------------------------ ledger --
  4574.  
  4575. local function ledger()
  4576.     local scroll = 0
  4577.     while true do
  4578.         ui.clear(colors.white)
  4579.         ui.topBar("Ledger")
  4580.         ui.center(9, "Loading...", colors.gray, colors.white)
  4581.         local resp, err = ask(bankId(), "pocketos.bank", { type = "txns", limit = 60 })
  4582.         ui.clear(colors.white)
  4583.         ui.topBar("Ledger", "\21", colors.blue)
  4584.         local rows
  4585.         if not resp or not resp.txns then
  4586.             ui.center(8, "Bank offline", colors.red, colors.white)
  4587.             ui.center(10, tostring(err or ""):sub(1, 24), colors.gray, colors.white)
  4588.         else
  4589.             rows = resp.txns
  4590.             local y = 2
  4591.             for i = scroll + 1, #rows do
  4592.                 if y > ui.h - 1 then break end
  4593.                 local t = rows[i]
  4594.                 local line
  4595.                 if t.kind == "deposit" then
  4596.                     line = "ATM -> #" .. t.to
  4597.                 else
  4598.                     line = "#" .. t.from .. " -> #" .. t.to
  4599.                 end
  4600.                 ui.text(2, y, (t.time or "") .. " " .. line, colors.black, colors.white)
  4601.                 local amt = "$" .. t.amount
  4602.                 ui.text(ui.w - #amt, y,
  4603.                     amt, t.kind == "deposit" and colors.green or colors.blue, colors.white)
  4604.                 y = y + 1
  4605.             end
  4606.             if #rows == 0 then
  4607.                 ui.center(9, "No transactions yet", colors.gray, colors.white)
  4608.             end
  4609.         end
  4610.         ui.center(ui.h, "Scroll; back to exit", colors.lightGray, colors.white)
  4611.         local e, a, mx, my = os.pullEvent()
  4612.         if e == "mouse_click" then
  4613.             if ui.isBack(mx, my) then return end
  4614.             if my == 1 and mx >= ui.w - 2 then scroll = 0 end -- refresh
  4615.         elseif e == "mouse_scroll" and rows then
  4616.             scroll = math.max(0, math.min(scroll + a, math.max(0, #rows - 1)))
  4617.         end
  4618.     end
  4619. end
  4620.  
  4621. --------------------------------------------------------------- menu --
  4622.  
  4623. ------------------------------------------------------------- bans --
  4624.  
  4625. local function bans()
  4626.     local target = askID("Ban User", "Ban/unban which ID?")
  4627.     if not target then return end
  4628.     ctx.save("dev_last", target)
  4629.     while true do
  4630.         ui.clear(colors.white)
  4631.         ui.topBar("Ban #" .. target)
  4632.         ui.center(3, "Manage device #" .. target, colors.gray, colors.white)
  4633.         local banBtn = ui.button(math.floor((ui.w - 17) / 2) + 1, 6, "Ban with reason", colors.red)
  4634.         local unBtn = ui.button(math.floor((ui.w - 7) / 2) + 1, 9, "Unban", colors.green)
  4635.         ui.center(12, "Banning locks their OS", colors.lightGray, colors.white)
  4636.         ui.center(13, "until you unban them.", colors.lightGray, colors.white)
  4637.         ui.center(ui.h, "Target must be online", colors.lightGray, colors.white)
  4638.  
  4639.         local e, a, mx, my = os.pullEvent("mouse_click")
  4640.         if ui.isBack(mx, my) then return end
  4641.         if ui.hit(banBtn, mx, my) then
  4642.             ui.clear(colors.white)
  4643.             ui.topBar("Ban #" .. target)
  4644.             ui.text(2, 3, "Reason for the ban:", colors.gray, colors.white)
  4645.             ui.fill(2, 5, ui.w - 2, 1, colors.lightGray)
  4646.             local reason = ui.input(3, 5, { width = ui.w - 4 })
  4647.             if reason ~= nil and reason:gsub("%s", "") ~= "" then
  4648.                 local pick = ui.dialog("Confirm Ban",
  4649.                     { "Lock device #" .. target .. "?" },
  4650.                     { { "Ban", colors.red }, { "Cancel", colors.gray } })
  4651.                 if pick == 1 then
  4652.                     local resp, err = ask(target, "pocketos.dev",
  4653.                         { type = "ban", reason = reason }, 3)
  4654.                     if resp and resp.ok then
  4655.                         ui.dialog("Banned", { "#" .. target .. " is locked." },
  4656.                             { { "OK", colors.blue } })
  4657.                     else
  4658.                         ui.dialog("No Response",
  4659.                             { "Device offline?", "Ban only applies", "while it's online." },
  4660.                             { { "OK", colors.blue } })
  4661.                     end
  4662.                     return
  4663.                 end
  4664.             end
  4665.         elseif ui.hit(unBtn, mx, my) then
  4666.             local resp, err = ask(target, "pocketos.dev", { type = "unban" })
  4667.             if resp and resp.ok then
  4668.                 ui.dialog("Unbanned", { "#" .. target .. " is free." },
  4669.                     { { "OK", colors.blue } })
  4670.             else
  4671.                 ui.dialog("No Response",
  4672.                     { tostring(err or "Offline"):sub(1, 18) }, { { "OK", colors.blue } })
  4673.             end
  4674.             return
  4675.         end
  4676.     end
  4677. end
  4678.  
  4679. --------------------------------------------------------------- menu --
  4680.  
  4681. function M.run(u, c)
  4682.     ui, ctx = u, c
  4683.     local tools = {
  4684.         { name = "Remote Shell", desc = "Run commands remotely", run = remoteShell, color = colors.cyan },
  4685.         { name = "Fleet",        desc = "Device locations",      run = fleet,       color = colors.orange },
  4686.         { name = "Ledger",       desc = "Bank transactions",     run = ledger,      color = colors.green },
  4687.         { name = "Ban User",     desc = "Lock a device out",     run = bans,        color = colors.red },
  4688.     }
  4689.     while true do
  4690.         ui.clear(colors.white)
  4691.         ui.topBar("Developer")
  4692.         local rows = {}
  4693.         local y = 3
  4694.         for _, t in ipairs(tools) do
  4695.             ui.card(2, y, ui.w - 3, 3, t.color)
  4696.             ui.text(4, y + 1, t.name, colors.white, t.color)
  4697.             ui.text(4, y + 2, t.desc, colors.white, t.color)
  4698.             rows[#rows + 1] = { y1 = y, y2 = y + 2, run = t.run }
  4699.             y = y + 4
  4700.         end
  4701.         ui.center(ui.h, "Dev mode: #" .. os.getComputerID(), colors.lightGray, colors.white)
  4702.         local e, a, mx, my = os.pullEvent("mouse_click")
  4703.         if ui.isBack(mx, my) then return end
  4704.         for _, r in ipairs(rows) do
  4705.             if my >= r.y1 and my <= r.y2 then
  4706.                 r.run()
  4707.                 break
  4708.             end
  4709.         end
  4710.     end
  4711. end
  4712.  
  4713. return M
  4714. ]==],
  4715. ["pocketos/apps/settings.lua"] = [==[
  4716. -- PocketOS app: Settings + Software Update.
  4717.  
  4718. local M = {}
  4719.  
  4720. local function doUpdate(ui, ctx)
  4721.     ui.clear(colors.white)
  4722.     ui.topBar("Update")
  4723.     ui.text(2, 3, "Installed: PocketOS " .. ctx.version, colors.gray, colors.white)
  4724.     ui.text(2, 5, "Pastebin code or URL", colors.gray, colors.white)
  4725.     ui.text(2, 6, "of the new installer:", colors.gray, colors.white)
  4726.     ui.fill(2, 8, ui.w - 2, 1, colors.lightGray)
  4727.     -- prefill: last code actually used wins, else the config default
  4728.     local def = ctx.load("update_src", "")
  4729.     if def == "" then
  4730.         local cu = ctx.config and ctx.config.update
  4731.         if type(cu) == "string" then def = cu end
  4732.     end
  4733.     local s = ui.input(3, 8, { default = def, width = ui.w - 4 })
  4734.     if s == nil then return end
  4735.     s = s:gsub("%s", "")
  4736.     if s == "" then return end
  4737.     if not http then
  4738.         ui.center(11, "HTTP is disabled :(", colors.red, colors.white)
  4739.         sleep(1.5)
  4740.         return
  4741.     end
  4742.     local url = s:match("^https?://") and s or ("https://pastebin.com/raw/" .. s)
  4743.     ui.center(11, "Downloading...", colors.gray, colors.white)
  4744.     local ok, res = pcall(http.get, url)
  4745.     if not ok or not res then
  4746.         ui.center(13, "Download failed", colors.red, colors.white)
  4747.         sleep(1.5)
  4748.         return
  4749.     end
  4750.     local body = res.readAll() or ""
  4751.     res.close()
  4752.     if not body:find("PocketOS installer", 1, true) then
  4753.         ui.center(13, "Not a PocketOS installer", colors.red, colors.white)
  4754.         sleep(1.8)
  4755.         return
  4756.     end
  4757.     ctx.save("update_src", s)
  4758.     local pick = ui.dialog("Install?",
  4759.         { "Music, messages and", "scores are kept.", "Reboots when done." },
  4760.         { { "Install", colors.blue }, { "Cancel", colors.gray } })
  4761.     if pick ~= 1 then return end
  4762.     term.setBackgroundColor(colors.black)
  4763.     term.setTextColor(colors.white)
  4764.     term.clear()
  4765.     term.setCursorPos(1, 1)
  4766.     local fn, err = load(body, "installer", "t", _ENV)
  4767.     if not fn then
  4768.         ui.clear(colors.white)
  4769.         ui.center(9, "Installer is broken:", colors.red, colors.white)
  4770.         ui.center(11, tostring(err):sub(1, 24), colors.gray, colors.white)
  4771.         sleep(2)
  4772.         return
  4773.     end
  4774.     local ok2, err2 = pcall(fn) -- writes files and reboots
  4775.     if not ok2 then
  4776.         print("Update failed: " .. tostring(err2))
  4777.         print("Re-run the update, or reinstall.")
  4778.         sleep(3)
  4779.     end
  4780. end
  4781.  
  4782. local function factoryReset(ui, ctx)
  4783.     local pick = ui.dialog("Factory Reset?",
  4784.         { "Erases ALL data:", "messages, scores,", "songs, keys, settings." },
  4785.         { { "Erase", colors.red }, { "Cancel", colors.gray } })
  4786.     if pick ~= 1 then return end
  4787.     local sure = ui.dialog("Are you sure?",
  4788.         { "This cannot be undone." },
  4789.         { { "Erase All", colors.red }, { "Cancel", colors.gray } })
  4790.     if sure ~= 1 then return end
  4791.  
  4792.     if fs.exists(ctx.dataDir) then fs.delete(ctx.dataDir) end
  4793.     if fs.exists("/music") then fs.delete("/music") end
  4794.     pcall(os.setComputerLabel, nil)
  4795.  
  4796.     ui.clear(colors.white)
  4797.     ui.center(9, "Erased.", colors.black, colors.white)
  4798.     ui.center(11, "Rebooting...", colors.gray, colors.white)
  4799.     sleep(1.2)
  4800.     os.reboot()
  4801. end
  4802.  
  4803. local function timeSettings(ui, ctx)
  4804.     while true do
  4805.         ui.clear(colors.white)
  4806.         ui.topBar("Date & Time")
  4807.         ui.center(3, ui.clock(), colors.black, colors.white)
  4808.         ui.center(4, "live preview", colors.lightGray, colors.white)
  4809.  
  4810.         ui.text(2, 7, "Format", colors.black, colors.white)
  4811.         local b12 = ui.button(ui.w - 13, 7, "12h",
  4812.             ui.time24 and colors.lightGray or colors.blue,
  4813.             ui.time24 and colors.gray or colors.white)
  4814.         local b24 = ui.button(ui.w - 6, 7, "24h",
  4815.             ui.time24 and colors.blue or colors.lightGray,
  4816.             ui.time24 and colors.white or colors.gray)
  4817.  
  4818.         ui.text(2, 10, "Time zone", colors.black, colors.white)
  4819.         local tz = "UTC" .. (ui.tzOffset >= 0 and "+" or "") .. tostring(ui.tzOffset)
  4820.         local minus = ui.button(3, 12, "-", colors.blue)
  4821.         ui.center(12, tz, colors.black, colors.white)
  4822.         local plus = ui.button(ui.w - 3, 12, "+", colors.blue)
  4823.  
  4824.         ui.center(ui.h, "Clock is UTC + offset", colors.lightGray, colors.white)
  4825.  
  4826.         local e, b, mx, my = os.pullEvent("mouse_click")
  4827.         if ui.isBack(mx, my) then return end
  4828.         if ui.hit(b12, mx, my) then
  4829.             ui.time24 = false
  4830.             ctx.save("time_24h", false)
  4831.         elseif ui.hit(b24, mx, my) then
  4832.             ui.time24 = true
  4833.             ctx.save("time_24h", true)
  4834.         elseif ui.hit(minus, mx, my) then
  4835.             ui.tzOffset = math.max(-12, ui.tzOffset - 1)
  4836.             ctx.save("tz_offset", ui.tzOffset)
  4837.         elseif ui.hit(plus, mx, my) then
  4838.             ui.tzOffset = math.min(14, ui.tzOffset + 1)
  4839.             ctx.save("tz_offset", ui.tzOffset)
  4840.         end
  4841.     end
  4842. end
  4843.  
  4844. function M.run(ui, ctx)
  4845.     local idTaps = 0
  4846.     while true do
  4847.         ui.clear(colors.white)
  4848.         ui.topBar("Settings")
  4849.  
  4850.         local trk = ctx.config and tonumber(ctx.config.tracker)
  4851.         local tz = "UTC" .. (ui.tzOffset >= 0 and "+" or "") .. tostring(ui.tzOffset)
  4852.         local items = {
  4853.             { y = 3,  label = "Name",        value = os.getComputerLabel() or "Not set", act = "name", tap = true },
  4854.             { y = 5,  label = "Computer ID", value = "#" .. os.getComputerID(), act = "devcount" },
  4855.             { y = 7,  label = "Date & Time", value = (ui.time24 and "24h " or "12h ") .. tz, act = "time", tap = true },
  4856.             { y = 9,  label = "Software Update", value = "", act = "update", tap = true },
  4857.             { y = 11, label = "Wireless",    value = ctx.modem and "On" or "Off" },
  4858.             { y = 12, label = "Tracker",     value = trk and ("#" .. trk) or "Off" },
  4859.             { y = 13, label = "Version",     value = "PocketOS " .. ctx.version },
  4860.         }
  4861.         if ctx.dev then
  4862.             items[#items + 1] = { y = 15, label = "Developer Mode", value = "On", act = "dev", tap = true }
  4863.         end
  4864.         for _, it in ipairs(items) do
  4865.             ui.text(2, it.y, it.label, colors.black, colors.white)
  4866.             local v = tostring(it.value):sub(1, 12)
  4867.             local off = it.tap and 2 or 0
  4868.             if #v > 0 then
  4869.                 ui.text(ui.w - #v - off, it.y,
  4870.                     it.act == "dev" and "\7 " .. v or v,
  4871.                     it.act == "dev" and colors.green or colors.gray, colors.white)
  4872.             end
  4873.             if it.tap then ui.text(ui.w - 1, it.y, ">", colors.gray, colors.white) end
  4874.         end
  4875.  
  4876.         local reboot = ui.button(3, 17, "Reboot", colors.blue)
  4877.         local off = ui.button(14, 17, "Shut Down", colors.red)
  4878.         local reset = ui.button(math.floor((ui.w - 15) / 2) + 1, 19, "Factory Reset", colors.red)
  4879.  
  4880.         local e, b, mx, my = os.pullEvent("mouse_click")
  4881.         if ui.isBack(mx, my) then return end
  4882.  
  4883.         local clicked
  4884.         for _, it in ipairs(items) do
  4885.             if it.y == my then clicked = it end
  4886.         end
  4887.         if clicked and clicked.act == "devcount" then
  4888.             -- tap the Computer ID row 7 times to toggle developer mode
  4889.             idTaps = idTaps + 1
  4890.             local admin = ctx.isAdmin and ctx.isAdmin(os.getComputerID())
  4891.             if not ctx.dev and idTaps >= 7 then
  4892.                 idTaps = 0
  4893.                 if admin then
  4894.                     ctx.dev = true
  4895.                     ctx.save("devmode", true)
  4896.                     ui.dialog("Developer Mode",
  4897.                         { "You are now a", "developer!" }, { { "Nice", colors.green } })
  4898.                 else
  4899.                     ui.dialog("Restricted",
  4900.                         { "Developer access is", "for admins only." }, { { "OK", colors.blue } })
  4901.                 end
  4902.             elseif not ctx.dev and idTaps >= 3 then
  4903.                 ui.text(2, 6, (7 - idTaps) .. " taps to dev mode",
  4904.                     colors.lightGray, colors.white)
  4905.                 sleep(0.6)
  4906.             end
  4907.         elseif clicked and clicked.act == "name" then
  4908.             ui.fill(1, 3, ui.w, 1, colors.lightGray)
  4909.             local newName = ui.input(2, 3, { default = os.getComputerLabel() or "", width = ui.w - 2 })
  4910.             if newName and newName ~= "" then
  4911.                 os.setComputerLabel(newName)
  4912.             end
  4913.         elseif clicked and clicked.act == "time" then
  4914.             timeSettings(ui, ctx)
  4915.         elseif clicked and clicked.act == "update" then
  4916.             doUpdate(ui, ctx)
  4917.         elseif clicked and clicked.act == "dev" then
  4918.             local pick = ui.dialog("Developer Mode",
  4919.                 { "Turn OFF developer", "mode? Hides the Dev", "app and remote access." },
  4920.                 { { "Turn Off", colors.red }, { "Keep On", colors.gray } })
  4921.             if pick == 1 then
  4922.                 ctx.dev = false
  4923.                 ctx.save("devmode", false)
  4924.             end
  4925.         elseif ui.hit(reboot, mx, my) then
  4926.             os.reboot()
  4927.         elseif ui.hit(off, mx, my) then
  4928.             os.shutdown()
  4929.         elseif ui.hit(reset, mx, my) then
  4930.             factoryReset(ui, ctx)
  4931.         end
  4932.     end
  4933. end
  4934.  
  4935. return M
  4936. ]==],
  4937. ["pocketos/apps/games/snake.lua"] = [==[
  4938. -- PocketOS app: Snake
  4939. -- Arrow keys / WASD, or tap the screen on the side you want to turn.
  4940.  
  4941. local M = {}
  4942.  
  4943. local function keyDir(k)
  4944.     if k == keys.up or k == keys.w then return { x = 0, y = -1 } end
  4945.     if k == keys.down or k == keys.s then return { x = 0, y = 1 } end
  4946.     if k == keys.left or k == keys.a then return { x = -1, y = 0 } end
  4947.     if k == keys.right or k == keys.d then return { x = 1, y = 0 } end
  4948. end
  4949.  
  4950. local function game(ui, best)
  4951.     local x1, y1 = 2, 3
  4952.     local x2, y2 = ui.w - 1, ui.h - 1
  4953.  
  4954.     ui.clear(colors.white)
  4955.     ui.topBar("Snake", string.format("%4d", 0))
  4956.     ui.fill(1, 2, ui.w, 1, colors.lightGray)
  4957.     ui.fill(1, ui.h, ui.w, 1, colors.lightGray)
  4958.     ui.fill(1, 2, 1, ui.h - 1, colors.lightGray)
  4959.     ui.fill(ui.w, 2, 1, ui.h - 1, colors.lightGray)
  4960.  
  4961.     local function cell(x, y, c)
  4962.         term.setCursorPos(x, y)
  4963.         term.setBackgroundColor(c)
  4964.         term.write(" ")
  4965.     end
  4966.  
  4967.     local occ = {}
  4968.     for y = y1, y2 do occ[y] = {} end
  4969.  
  4970.     local cx = math.floor((x1 + x2) / 2)
  4971.     local cy = math.floor((y1 + y2) / 2)
  4972.     local snake = { { x = cx, y = cy }, { x = cx - 1, y = cy }, { x = cx - 2, y = cy } }
  4973.     for i, s in ipairs(snake) do
  4974.         occ[s.y][s.x] = true
  4975.         cell(s.x, s.y, i == 1 and colors.green or colors.lime)
  4976.     end
  4977.  
  4978.     local dir = { x = 1, y = 0 }
  4979.     local nextDir = dir
  4980.     local score = 0
  4981.     local interval = 0.18
  4982.  
  4983.     local food = {}
  4984.     local function placeFood()
  4985.         while true do
  4986.             local fx = math.random(x1, x2)
  4987.             local fy = math.random(y1, y2)
  4988.             if not occ[fy][fx] then
  4989.                 food.x, food.y = fx, fy
  4990.                 cell(fx, fy, colors.red)
  4991.                 return
  4992.             end
  4993.         end
  4994.     end
  4995.     placeFood()
  4996.  
  4997.     local hintY = y2 - 1
  4998.     ui.center(hintY, "Tap or use arrow keys", colors.gray, colors.white)
  4999.  
  5000.     -- wait for the first input before moving
  5001.     while true do
  5002.         local e, a, b, c = os.pullEvent()
  5003.         if e == "key" then
  5004.             local d = keyDir(a)
  5005.             if d then
  5006.                 if not (d.x == -dir.x and d.y == -dir.y) then nextDir = d end
  5007.                 break
  5008.             end
  5009.         elseif e == "mouse_click" then
  5010.             if ui.isBack(b, c) then return "home", score end
  5011.             if c >= y1 then break end
  5012.         end
  5013.     end
  5014.     ui.fill(x1, hintY, x2 - x1 + 1, 1, colors.white)
  5015.     if food.y == hintY then cell(food.x, food.y, colors.red) end
  5016.     for i, s in ipairs(snake) do
  5017.         if s.y == hintY then cell(s.x, s.y, i == 1 and colors.green or colors.lime) end
  5018.     end
  5019.  
  5020.     local timer = os.startTimer(interval)
  5021.     while true do
  5022.         local e, a, b, c = os.pullEvent()
  5023.         if e == "timer" and a == timer then
  5024.             if not (nextDir.x == -dir.x and nextDir.y == -dir.y) then dir = nextDir end
  5025.             local head = snake[1]
  5026.             local hx, hy = head.x + dir.x, head.y + dir.y
  5027.             if hx < x1 or hx > x2 or hy < y1 or hy > y2 or occ[hy][hx] then
  5028.                 cell(head.x, head.y, colors.red)
  5029.                 local pick = ui.dialog("Game Over",
  5030.                     { "Score: " .. score, "Best: " .. math.max(best, score) },
  5031.                     { { "Again", colors.blue }, { "Home", colors.gray } })
  5032.                 return pick == 1 and "again" or "home", score
  5033.             end
  5034.             table.insert(snake, 1, { x = hx, y = hy })
  5035.             occ[hy][hx] = true
  5036.             cell(head.x, head.y, colors.lime)
  5037.             cell(hx, hy, colors.green)
  5038.             if hx == food.x and hy == food.y then
  5039.                 score = score + 1
  5040.                 interval = math.max(0.08, 0.18 - score * 0.004)
  5041.                 ui.text(ui.w - 4, 1, string.format("%4d", score), colors.gray, colors.white)
  5042.                 if #snake >= (x2 - x1 + 1) * (y2 - y1 + 1) - 1 then
  5043.                     local pick = ui.dialog("You Win!",
  5044.                         { "Score: " .. score },
  5045.                         { { "Again", colors.blue }, { "Home", colors.gray } })
  5046.                     return pick == 1 and "again" or "home", score
  5047.                 end
  5048.                 placeFood()
  5049.             else
  5050.                 local tail = table.remove(snake)
  5051.                 occ[tail.y][tail.x] = nil
  5052.                 cell(tail.x, tail.y, colors.white)
  5053.             end
  5054.             timer = os.startTimer(interval)
  5055.         elseif e == "key" then
  5056.             local d = keyDir(a)
  5057.             if d and not (d.x == -dir.x and d.y == -dir.y) then nextDir = d end
  5058.         elseif e == "mouse_click" then
  5059.             if ui.isBack(b, c) then return "home", score end
  5060.             if c >= y1 then
  5061.                 local head = snake[1]
  5062.                 local dx, dy = b - head.x, c - head.y
  5063.                 local d
  5064.                 if math.abs(dx) > math.abs(dy) then
  5065.                     d = { x = dx > 0 and 1 or -1, y = 0 }
  5066.                 else
  5067.                     d = { x = 0, y = dy > 0 and 1 or -1 }
  5068.                 end
  5069.                 if d.x == -dir.x and d.y == -dir.y then
  5070.                     -- reversing isn't allowed; use the other axis instead
  5071.                     if d.x ~= 0 then
  5072.                         d = { x = 0, y = dy >= 0 and 1 or -1 }
  5073.                     else
  5074.                         d = { x = dx >= 0 and 1 or -1, y = 0 }
  5075.                     end
  5076.                 end
  5077.                 if not (d.x == -dir.x and d.y == -dir.y) then nextDir = d end
  5078.             end
  5079.         end
  5080.     end
  5081. end
  5082.  
  5083. function M.run(ui, ctx)
  5084.     local best = ctx.load("snake_best", 0)
  5085.     while true do
  5086.         local action, score = game(ui, best)
  5087.         if score and score > best then
  5088.             best = score
  5089.             ctx.save("snake_best", best)
  5090.         end
  5091.         if action ~= "again" then return end
  5092.     end
  5093. end
  5094.  
  5095. return M
  5096. ]==],
  5097. ["pocketos/apps/games/blocks.lua"] = [==[
  5098. -- PocketOS app: Blocks - a Block Blast style puzzle.
  5099. -- Tap a piece in the tray, then tap (or drag onto) the 8x8 board.
  5100. -- Full rows and columns clear. Game ends when nothing fits.
  5101.  
  5102. local M = {}
  5103.  
  5104. local N = 8
  5105.  
  5106. local SHAPES = {
  5107.     { "#" },
  5108.     { "##" }, { "#", "#" },
  5109.     { "###" }, { "#", "#", "#" },
  5110.     { "####" }, { "#", "#", "#", "#" },
  5111.     { "#####" }, { "#", "#", "#", "#", "#" },
  5112.     { "##", "##" },
  5113.     { "###", "###", "###" },
  5114.     { "#.", "##" }, { ".#", "##" }, { "##", "#." }, { "##", ".#" },
  5115.     { "#..", "#..", "###" }, { "..#", "..#", "###" },
  5116.     { "###", "#.." }, { "###", "..#" },
  5117.     { "###", ".#." }, { ".#.", "###" },
  5118.     { "#.", "##", "#." }, { ".#", "##", ".#" },
  5119. }
  5120.  
  5121. local PALETTE = {
  5122.     colors.blue, colors.red, colors.green, colors.orange,
  5123.     colors.purple, colors.cyan, colors.pink,
  5124. }
  5125.  
  5126. local PIECES = {}
  5127. for _, rows in ipairs(SHAPES) do
  5128.     local cells, w = {}, 0
  5129.     for y, row in ipairs(rows) do
  5130.         w = math.max(w, #row)
  5131.         for x = 1, #row do
  5132.             if row:sub(x, x) == "#" then
  5133.                 cells[#cells + 1] = { x = x - 1, y = y - 1 }
  5134.             end
  5135.         end
  5136.     end
  5137.     PIECES[#PIECES + 1] = { cells = cells, w = w, h = #rows }
  5138. end
  5139.  
  5140. local toBlit = colors.toBlit or function(c)
  5141.     local i, v = 0, c
  5142.     while v > 1 do
  5143.         v = math.floor(v / 2)
  5144.         i = i + 1
  5145.     end
  5146.     return ("0123456789abcdef"):sub(i + 1, i + 1)
  5147. end
  5148.  
  5149. local function newPiece()
  5150.     local p = PIECES[math.random(#PIECES)]
  5151.     return { cells = p.cells, w = p.w, h = p.h, color = PALETTE[math.random(#PALETTE)] }
  5152. end
  5153.  
  5154. local function canPlace(grid, p, ax, ay)
  5155.     for _, c in ipairs(p.cells) do
  5156.         local x, y = ax + c.x, ay + c.y
  5157.         if x < 1 or x > N or y < 1 or y > N or grid[y][x] then return false end
  5158.     end
  5159.     return true
  5160. end
  5161.  
  5162. local function anyMove(grid, tray)
  5163.     for _, p in pairs(tray) do
  5164.         for ay = 1, N - p.h + 1 do
  5165.             for ax = 1, N - p.w + 1 do
  5166.                 if canPlace(grid, p, ax, ay) then return true end
  5167.             end
  5168.         end
  5169.     end
  5170.     return false
  5171. end
  5172.  
  5173. local function clearFull(grid)
  5174.     local rows, cols = {}, {}
  5175.     for y = 1, N do
  5176.         local full = true
  5177.         for x = 1, N do
  5178.             if not grid[y][x] then full = false; break end
  5179.         end
  5180.         if full then rows[#rows + 1] = y end
  5181.     end
  5182.     for x = 1, N do
  5183.         local full = true
  5184.         for y = 1, N do
  5185.             if not grid[y][x] then full = false; break end
  5186.         end
  5187.         if full then cols[#cols + 1] = x end
  5188.     end
  5189.     for _, y in ipairs(rows) do
  5190.         for x = 1, N do grid[y][x] = nil end
  5191.     end
  5192.     for _, x in ipairs(cols) do
  5193.         for y = 1, N do grid[y][x] = nil end
  5194.     end
  5195.     return #rows + #cols
  5196. end
  5197.  
  5198. local function game(ui, best)
  5199.     local boardX = math.floor((ui.w - N * 2) / 2) + 1
  5200.     local boardY = 4
  5201.     local trayY = 14
  5202.  
  5203.     local grid = {}
  5204.     for y = 1, N do grid[y] = {} end
  5205.     local tray = { newPiece(), newPiece(), newPiece() }
  5206.     local score = 0
  5207.     local selected, ghost
  5208.  
  5209.     local function drawScore()
  5210.         ui.fill(1, 2, ui.w, 1, colors.white)
  5211.         ui.text(2, 2, "Score " .. score, colors.black, colors.white)
  5212.         local b = "Best " .. math.max(best, score)
  5213.         ui.text(ui.w - #b, 2, b, colors.gray, colors.white)
  5214.     end
  5215.  
  5216.     local function drawBoard()
  5217.         for y = 1, N do
  5218.             local txt, fg, bg = "", "", ""
  5219.             for x = 1, N do
  5220.                 local c = grid[y][x]
  5221.                 if not c and ghost and ghost.set[y * 16 + x] then c = colors.gray end
  5222.                 local h = toBlit(c or colors.lightGray)
  5223.                 txt = txt .. "  "
  5224.                 fg = fg .. h .. h
  5225.                 bg = bg .. h .. h
  5226.             end
  5227.             term.setCursorPos(boardX, boardY + y - 1)
  5228.             term.blit(txt, fg, bg)
  5229.         end
  5230.     end
  5231.  
  5232.     local function slotX(i) return 2 + (i - 1) * 8 end
  5233.  
  5234.     local function drawTray()
  5235.         ui.fill(1, trayY - 1, ui.w, 7, colors.white)
  5236.         for i = 1, 3 do
  5237.             local p = tray[i]
  5238.             if p then
  5239.                 local sx = slotX(i)
  5240.                 if selected == i then
  5241.                     ui.fill(sx - 1, trayY - 1, 9, 7, colors.lightGray)
  5242.                 end
  5243.                 local ox = sx + math.floor((7 - p.w) / 2)
  5244.                 local oy = trayY + math.floor((5 - p.h) / 2)
  5245.                 for _, c in ipairs(p.cells) do
  5246.                     ui.fill(ox + c.x, oy + c.y, 1, 1, p.color)
  5247.                 end
  5248.             end
  5249.         end
  5250.     end
  5251.  
  5252.     local function onBoard(mx, my)
  5253.         return mx >= boardX and mx <= boardX + N * 2 - 1
  5254.             and my >= boardY and my <= boardY + N - 1
  5255.     end
  5256.  
  5257.     local function slotAt(mx, my)
  5258.         if my < trayY - 1 or my > trayY + 5 then return nil end
  5259.         for i = 1, 3 do
  5260.             local sx = slotX(i)
  5261.             if mx >= sx - 1 and mx <= sx + 7 then return i end
  5262.         end
  5263.     end
  5264.  
  5265.     local function makeGhost(mx, my)
  5266.         local p = tray[selected]
  5267.         local bx = math.floor((mx - boardX) / 2) + 1
  5268.         local by = my - boardY + 1
  5269.         local ax = math.max(1, math.min(bx - math.floor((p.w - 1) / 2), N - p.w + 1))
  5270.         local ay = math.max(1, math.min(by - math.floor((p.h - 1) / 2), N - p.h + 1))
  5271.         local g = { ax = ax, ay = ay, valid = canPlace(grid, p, ax, ay), set = {} }
  5272.         if g.valid then
  5273.             for _, c in ipairs(p.cells) do
  5274.                 g.set[(ay + c.y) * 16 + (ax + c.x)] = true
  5275.             end
  5276.         end
  5277.         return g
  5278.     end
  5279.  
  5280.     ui.clear(colors.white)
  5281.     ui.topBar("Blocks")
  5282.     drawScore()
  5283.     drawBoard()
  5284.     drawTray()
  5285.     ui.center(ui.h, "Tap a piece, then board", colors.gray, colors.white)
  5286.  
  5287.     while true do
  5288.         local e, b, mx, my = os.pullEvent()
  5289.         if e == "mouse_click" then
  5290.             if ui.isBack(mx, my) then return "home", score end
  5291.             local s = slotAt(mx, my)
  5292.             if s and tray[s] then
  5293.                 selected = (selected ~= s) and s or nil
  5294.                 ghost = nil
  5295.                 drawTray()
  5296.                 drawBoard()
  5297.             elseif selected and onBoard(mx, my) then
  5298.                 ghost = makeGhost(mx, my)
  5299.                 drawBoard()
  5300.             end
  5301.         elseif e == "mouse_drag" and selected then
  5302.             ghost = onBoard(mx, my) and makeGhost(mx, my) or nil
  5303.             drawBoard()
  5304.         elseif e == "mouse_up" and ghost then
  5305.             if ghost.valid then
  5306.                 local p = tray[selected]
  5307.                 for _, c in ipairs(p.cells) do
  5308.                     grid[ghost.ay + c.y][ghost.ax + c.x] = p.color
  5309.                 end
  5310.                 score = score + #p.cells
  5311.                 local lines = clearFull(grid)
  5312.                 if lines > 0 then score = score + lines * lines * 10 end
  5313.                 tray[selected] = nil
  5314.                 selected, ghost = nil, nil
  5315.                 if not (tray[1] or tray[2] or tray[3]) then
  5316.                     tray = { newPiece(), newPiece(), newPiece() }
  5317.                 end
  5318.                 drawScore()
  5319.                 drawBoard()
  5320.                 drawTray()
  5321.                 if not anyMove(grid, tray) then
  5322.                     local pick = ui.dialog("Game Over",
  5323.                         { "Score: " .. score, "Best: " .. math.max(best, score) },
  5324.                         { { "Again", colors.blue }, { "Home", colors.gray } })
  5325.                     return pick == 1 and "again" or "home", score
  5326.                 end
  5327.             else
  5328.                 ghost = nil
  5329.                 drawBoard()
  5330.             end
  5331.         end
  5332.     end
  5333. end
  5334.  
  5335. function M.run(ui, ctx)
  5336.     local best = ctx.load("blocks_best", 0)
  5337.     while true do
  5338.         local action, score = game(ui, best)
  5339.         if score and score > best then
  5340.             best = score
  5341.             ctx.save("blocks_best", best)
  5342.         end
  5343.         if action ~= "again" then return end
  5344.     end
  5345. end
  5346.  
  5347. return M
  5348. ]==],
  5349. ["pocketos/apps/games/match.lua"] = [==[
  5350. -- PocketOS app: Match - a Royal Match style match-3.
  5351. -- Tap a gem, then an adjacent one (or drag) to swap. Line up 3+ of a
  5352. -- kind to clear them; cascades multiply the score. 25 moves per game.
  5353.  
  5354. local M = {}
  5355.  
  5356. local N = 8
  5357. local MOVES = 25
  5358.  
  5359. local KINDS = {
  5360.     { c = colors.red,    g = "\3" },
  5361.     { c = colors.yellow, g = "\15" },
  5362.     { c = colors.blue,   g = "\4" },
  5363.     { c = colors.green,  g = "\5" },
  5364.     { c = colors.purple, g = "\6" },
  5365. }
  5366.  
  5367. local function game(ui, ctx, best)
  5368.     local x0, y0 = 6, 4
  5369.     local grid = {}
  5370.     local score = 0
  5371.     local moves = MOVES
  5372.     local sel = nil
  5373.  
  5374.     local function randKind(x, y)
  5375.         while true do
  5376.             local k = math.random(#KINDS)
  5377.             local a = grid[y] and grid[y][x - 1]
  5378.             local b = grid[y] and grid[y][x - 2]
  5379.             local c = grid[y - 1] and grid[y - 1][x]
  5380.             local d = grid[y - 2] and grid[y - 2][x]
  5381.             if not ((a == k and b == k) or (c == k and d == k)) then
  5382.                 return k
  5383.             end
  5384.         end
  5385.     end
  5386.  
  5387.     for y = 1, N do
  5388.         grid[y] = {}
  5389.         for x = 1, N do
  5390.             grid[y][x] = randKind(x, y)
  5391.         end
  5392.     end
  5393.  
  5394.     local function drawCell(x, y, hl)
  5395.         local k = grid[y][x]
  5396.         term.setCursorPos(x0 + (x - 1) * 2, y0 + y - 1)
  5397.         if not k then
  5398.             term.setBackgroundColor(colors.white)
  5399.             term.write("  ")
  5400.         else
  5401.             local kind = KINDS[k]
  5402.             if hl then
  5403.                 term.setBackgroundColor(colors.white)
  5404.                 term.setTextColor(kind.c)
  5405.             else
  5406.                 term.setBackgroundColor(kind.c)
  5407.                 term.setTextColor(colors.white)
  5408.             end
  5409.             term.write(kind.g .. " ")
  5410.         end
  5411.     end
  5412.  
  5413.     local function drawBoard()
  5414.         for y = 1, N do
  5415.             for x = 1, N do
  5416.                 drawCell(x, y, sel and sel.x == x and sel.y == y)
  5417.             end
  5418.         end
  5419.     end
  5420.  
  5421.     local function drawHud()
  5422.         ui.fill(1, 2, ui.w, 1, colors.white)
  5423.         ui.text(2, 2, "Score " .. score, colors.black, colors.white)
  5424.         local b = "Best " .. math.max(best, score)
  5425.         ui.text(ui.w - #b, 2, b, colors.gray, colors.white)
  5426.         ui.fill(1, 14, ui.w, 1, colors.white)
  5427.         ui.center(14, "Moves left: " .. moves,
  5428.             moves <= 5 and colors.red or colors.gray, colors.white)
  5429.     end
  5430.  
  5431.     local function findMatches()
  5432.         local mark, count = {}, 0
  5433.         for y = 1, N do
  5434.             local x = 1
  5435.             while x <= N do
  5436.                 local k = grid[y][x]
  5437.                 local len = 1
  5438.                 while k and x + len <= N and grid[y][x + len] == k do
  5439.                     len = len + 1
  5440.                 end
  5441.                 if k and len >= 3 then
  5442.                     for i = 0, len - 1 do
  5443.                         local key = y * 16 + x + i
  5444.                         if not mark[key] then
  5445.                             mark[key] = true
  5446.                             count = count + 1
  5447.                         end
  5448.                     end
  5449.                 end
  5450.                 x = x + len
  5451.             end
  5452.         end
  5453.         for x = 1, N do
  5454.             local y = 1
  5455.             while y <= N do
  5456.                 local k = grid[y][x]
  5457.                 local len = 1
  5458.                 while k and y + len <= N and grid[y + len][x] == k do
  5459.                     len = len + 1
  5460.                 end
  5461.                 if k and len >= 3 then
  5462.                     for i = 0, len - 1 do
  5463.                         local key = (y + i) * 16 + x
  5464.                         if not mark[key] then
  5465.                             mark[key] = true
  5466.                             count = count + 1
  5467.                         end
  5468.                     end
  5469.                 end
  5470.                 y = y + len
  5471.             end
  5472.         end
  5473.         return mark, count
  5474.     end
  5475.  
  5476.     local function resolve()
  5477.         local chain = 0
  5478.         while true do
  5479.             local mark, count = findMatches()
  5480.             if count == 0 then break end
  5481.             chain = chain + 1
  5482.             score = score + count * 10 * chain
  5483.             for key in pairs(mark) do
  5484.                 local x = key % 16
  5485.                 local y = math.floor(key / 16)
  5486.                 ui.fill(x0 + (x - 1) * 2, y0 + y - 1, 2, 1, colors.white)
  5487.             end
  5488.             sleep(0.15)
  5489.             for key in pairs(mark) do
  5490.                 local x = key % 16
  5491.                 local y = math.floor(key / 16)
  5492.                 grid[y][x] = nil
  5493.             end
  5494.             for x = 1, N do
  5495.                 local write = N
  5496.                 for y = N, 1, -1 do
  5497.                     if grid[y][x] then
  5498.                         local k = grid[y][x]
  5499.                         if write ~= y then grid[y][x] = nil end
  5500.                         grid[write][x] = k
  5501.                         write = write - 1
  5502.                     end
  5503.                 end
  5504.                 for y = write, 1, -1 do
  5505.                     grid[y][x] = math.random(#KINDS)
  5506.                 end
  5507.             end
  5508.             drawBoard()
  5509.             drawHud()
  5510.             sleep(0.1)
  5511.         end
  5512.     end
  5513.  
  5514.     local function hasMove()
  5515.         local function try(x1, y1, x2, y2)
  5516.             grid[y1][x1], grid[y2][x2] = grid[y2][x2], grid[y1][x1]
  5517.             local _, n = findMatches()
  5518.             grid[y1][x1], grid[y2][x2] = grid[y2][x2], grid[y1][x1]
  5519.             return n > 0
  5520.         end
  5521.         for y = 1, N do
  5522.             for x = 1, N do
  5523.                 if x < N and try(x, y, x + 1, y) then return true end
  5524.                 if y < N and try(x, y, x, y + 1) then return true end
  5525.             end
  5526.         end
  5527.         return false
  5528.     end
  5529.  
  5530.     local function ensureMoves()
  5531.         while not hasMove() do
  5532.             for y = 1, N do
  5533.                 for x = 1, N do grid[y][x] = nil end
  5534.             end
  5535.             for y = 1, N do
  5536.                 for x = 1, N do grid[y][x] = randKind(x, y) end
  5537.             end
  5538.             drawBoard()
  5539.             ui.center(14, "Shuffled!", colors.blue, colors.white)
  5540.             sleep(0.6)
  5541.             drawHud()
  5542.         end
  5543.     end
  5544.  
  5545.     local function trySwap(a, b)
  5546.         if math.abs(a.x - b.x) + math.abs(a.y - b.y) ~= 1 then return end
  5547.         grid[a.y][a.x], grid[b.y][b.x] = grid[b.y][b.x], grid[a.y][a.x]
  5548.         local _, n = findMatches()
  5549.         if n == 0 then
  5550.             -- not a match: show it, then swap back (no move used)
  5551.             drawCell(a.x, a.y)
  5552.             drawCell(b.x, b.y)
  5553.             sleep(0.15)
  5554.             grid[a.y][a.x], grid[b.y][b.x] = grid[b.y][b.x], grid[a.y][a.x]
  5555.             drawCell(a.x, a.y)
  5556.             drawCell(b.x, b.y)
  5557.             return
  5558.         end
  5559.         moves = moves - 1
  5560.         drawBoard()
  5561.         drawHud()
  5562.         resolve()
  5563.         drawBoard()
  5564.         drawHud()
  5565.         ensureMoves()
  5566.     end
  5567.  
  5568.     local function cellAt(mx, my)
  5569.         if my < y0 or my > y0 + N - 1 then return nil end
  5570.         local x = math.floor((mx - x0) / 2) + 1
  5571.         if x < 1 or x > N then return nil end
  5572.         return { x = x, y = my - y0 + 1 }
  5573.     end
  5574.  
  5575.     ui.clear(colors.white)
  5576.     ui.topBar("Match")
  5577.     drawHud()
  5578.     drawBoard()
  5579.     ui.center(16, "Tap two gems to swap", colors.lightGray, colors.white)
  5580.     ensureMoves()
  5581.  
  5582.     while true do
  5583.         local e, b, mx, my = os.pullEvent()
  5584.         if e == "mouse_click" then
  5585.             if ui.isBack(mx, my) then return "home", score end
  5586.             local c = cellAt(mx, my)
  5587.             if c then
  5588.                 if sel and sel.x == c.x and sel.y == c.y then
  5589.                     sel = nil
  5590.                     drawCell(c.x, c.y)
  5591.                 elseif sel and math.abs(sel.x - c.x) + math.abs(sel.y - c.y) == 1 then
  5592.                     local old = sel
  5593.                     sel = nil
  5594.                     drawCell(old.x, old.y)
  5595.                     trySwap(old, c)
  5596.                 else
  5597.                     local old = sel
  5598.                     sel = c
  5599.                     if old then drawCell(old.x, old.y) end
  5600.                     drawCell(c.x, c.y, true)
  5601.                 end
  5602.             end
  5603.         elseif e == "mouse_drag" and sel then
  5604.             local c = cellAt(mx, my)
  5605.             if c and math.abs(sel.x - c.x) + math.abs(sel.y - c.y) == 1 then
  5606.                 local old = sel
  5607.                 sel = nil
  5608.                 drawCell(old.x, old.y)
  5609.                 trySwap(old, c)
  5610.             end
  5611.         end
  5612.         if moves <= 0 then
  5613.             local pick = ui.dialog("Out of Moves!",
  5614.                 { "Score: " .. score, "Best: " .. math.max(best, score) },
  5615.                 { { "Again", colors.blue }, { "Home", colors.gray } })
  5616.             return (pick == 1 and "again" or "home"), score
  5617.         end
  5618.     end
  5619. end
  5620.  
  5621. function M.run(ui, ctx)
  5622.     local best = ctx.load("match_best", 0)
  5623.     while true do
  5624.         local action, score = game(ui, ctx, best)
  5625.         if score and score > best then
  5626.             best = score
  5627.             ctx.save("match_best", best)
  5628.         end
  5629.         if action ~= "again" then return end
  5630.     end
  5631. end
  5632.  
  5633. return M
  5634. ]==],
  5635. ["pocketos/apps/games/mines.lua"] = [==[
  5636. -- PocketOS app: Mines - classic minesweeper.
  5637. -- Tap to dig. Toggle Flag mode (top right) or right-click to flag.
  5638. -- First tap is always safe.
  5639.  
  5640. local M = {}
  5641.  
  5642. local W, H, MINES = 12, 14, 24
  5643.  
  5644. local NUMCOL = {
  5645.     colors.blue, colors.green, colors.red, colors.purple,
  5646.     colors.orange, colors.cyan, colors.black, colors.gray,
  5647. }
  5648.  
  5649. local function game(ui, ctx, best)
  5650.     local x0, y0 = 2, 4
  5651.     local mine, rev, flag = {}, {}, {}
  5652.     for y = 1, H do
  5653.         mine[y], rev[y], flag[y] = {}, {}, {}
  5654.     end
  5655.     local placed = false
  5656.     local flags = 0
  5657.     local opened = 0
  5658.     local flagMode = false
  5659.     local secs = 0
  5660.     local clock = os.startTimer(1)
  5661.  
  5662.     local function neighbors(x, y, fn)
  5663.         for dy = -1, 1 do
  5664.             for dx = -1, 1 do
  5665.                 local nx, ny = x + dx, y + dy
  5666.                 if (dx ~= 0 or dy ~= 0) and nx >= 1 and nx <= W and ny >= 1 and ny <= H then
  5667.                     fn(nx, ny)
  5668.                 end
  5669.             end
  5670.         end
  5671.     end
  5672.  
  5673.     local function countAround(x, y)
  5674.         local n = 0
  5675.         neighbors(x, y, function(nx, ny)
  5676.             if mine[ny][nx] then n = n + 1 end
  5677.         end)
  5678.         return n
  5679.     end
  5680.  
  5681.     local function drawCell(x, y)
  5682.         term.setCursorPos(x0 + (x - 1) * 2, y0 + y - 1)
  5683.         if rev[y][x] then
  5684.             if mine[y][x] then
  5685.                 term.setBackgroundColor(colors.red)
  5686.                 term.setTextColor(colors.black)
  5687.                 term.write("* ")
  5688.             else
  5689.                 local n = countAround(x, y)
  5690.                 term.setBackgroundColor(colors.white)
  5691.                 if n == 0 then
  5692.                     term.write("  ")
  5693.                 else
  5694.                     term.setTextColor(NUMCOL[n] or colors.black)
  5695.                     term.write(n .. " ")
  5696.                 end
  5697.             end
  5698.         else
  5699.             term.setBackgroundColor((x + y) % 2 == 0 and colors.lightGray or colors.gray)
  5700.             if flag[y][x] then
  5701.                 term.setTextColor(colors.red)
  5702.                 term.write("F ")
  5703.             else
  5704.                 term.write("  ")
  5705.             end
  5706.         end
  5707.     end
  5708.  
  5709.     local function drawTop()
  5710.         ui.fill(1, 2, ui.w, 1, colors.white)
  5711.         ui.text(2, 2, "\7 " .. (MINES - flags), colors.black, colors.white)
  5712.         local t = string.format("%d:%02d", math.floor(secs / 60), secs % 60)
  5713.         ui.center(2, t, colors.gray, colors.white)
  5714.         local lbl = flagMode and "Flag:ON " or "Flag:OFF"
  5715.         ui.text(ui.w - #lbl - 1, 2, " " .. lbl,
  5716.             flagMode and colors.white or colors.gray,
  5717.             flagMode and colors.red or colors.lightGray)
  5718.     end
  5719.  
  5720.     local function drawAll()
  5721.         ui.clear(colors.white)
  5722.         ui.topBar("Mines")
  5723.         drawTop()
  5724.         for y = 1, H do
  5725.             for x = 1, W do drawCell(x, y) end
  5726.         end
  5727.         ui.center(ui.h - 1, "Right-click also flags", colors.lightGray, colors.white)
  5728.     end
  5729.  
  5730.     local function placeMines(sx, sy)
  5731.         local safe = { [sy * 100 + sx] = true }
  5732.         neighbors(sx, sy, function(nx, ny) safe[ny * 100 + nx] = true end)
  5733.         local left = MINES
  5734.         while left > 0 do
  5735.             local x, y = math.random(W), math.random(H)
  5736.             if not mine[y][x] and not safe[y * 100 + x] then
  5737.                 mine[y][x] = true
  5738.                 left = left - 1
  5739.             end
  5740.         end
  5741.         placed = true
  5742.     end
  5743.  
  5744.     local function reveal(x, y)
  5745.         local stack = { { x, y } }
  5746.         while #stack > 0 do
  5747.             local c = table.remove(stack)
  5748.             local cx, cy = c[1], c[2]
  5749.             if not rev[cy][cx] and not flag[cy][cx] then
  5750.                 rev[cy][cx] = true
  5751.                 opened = opened + 1
  5752.                 drawCell(cx, cy)
  5753.                 if not mine[cy][cx] and countAround(cx, cy) == 0 then
  5754.                     neighbors(cx, cy, function(nx, ny)
  5755.                         if not rev[ny][nx] then stack[#stack + 1] = { nx, ny } end
  5756.                     end)
  5757.                 end
  5758.             end
  5759.         end
  5760.     end
  5761.  
  5762.     local function boardAt(mx, my)
  5763.         if my < y0 or my > y0 + H - 1 then return nil end
  5764.         local x = math.floor((mx - x0) / 2) + 1
  5765.         if x < 1 or x > W then return nil end
  5766.         return x, my - y0 + 1
  5767.     end
  5768.  
  5769.     drawAll()
  5770.     while true do
  5771.         local e, b, mx, my = os.pullEvent()
  5772.         if e == "timer" and b == clock then
  5773.             if placed then
  5774.                 secs = math.min(secs + 1, 5999)
  5775.                 drawTop()
  5776.             end
  5777.             clock = os.startTimer(1)
  5778.         elseif e == "mouse_click" then
  5779.             if ui.isBack(mx, my) then return "home" end
  5780.             if my == 2 and mx >= ui.w - 9 then
  5781.                 flagMode = not flagMode
  5782.                 drawTop()
  5783.             else
  5784.                 local x, y = boardAt(mx, my)
  5785.                 if x then
  5786.                     if flagMode or b == 2 then
  5787.                         if not rev[y][x] then
  5788.                             flag[y][x] = not flag[y][x]
  5789.                             flags = flags + (flag[y][x] and 1 or -1)
  5790.                             drawCell(x, y)
  5791.                             drawTop()
  5792.                         end
  5793.                     elseif not flag[y][x] and not rev[y][x] then
  5794.                         if not placed then placeMines(x, y) end
  5795.                         if mine[y][x] then
  5796.                             for yy = 1, H do
  5797.                                 for xx = 1, W do
  5798.                                     if mine[yy][xx] then
  5799.                                         rev[yy][xx] = true
  5800.                                         drawCell(xx, yy)
  5801.                                     end
  5802.                                 end
  5803.                             end
  5804.                             local pick = ui.dialog("Boom!",
  5805.                                 { "You hit a mine." },
  5806.                                 { { "Again", colors.blue }, { "Home", colors.gray } })
  5807.                             return pick == 1 and "again" or "home"
  5808.                         end
  5809.                         reveal(x, y)
  5810.                         if opened >= W * H - MINES then
  5811.                             local lines = { "Cleared in " .. secs .. "s!" }
  5812.                             if best and best > 0 then
  5813.                                 lines[2] = "Best: " .. best .. "s"
  5814.                             end
  5815.                             local pick = ui.dialog("You Win!", lines,
  5816.                                 { { "Again", colors.blue }, { "Home", colors.gray } })
  5817.                             return (pick == 1 and "again" or "home"), secs
  5818.                         end
  5819.                     end
  5820.                 end
  5821.             end
  5822.         end
  5823.     end
  5824. end
  5825.  
  5826. function M.run(ui, ctx)
  5827.     local best = ctx.load("mines_best", 0)
  5828.     while true do
  5829.         local action, secs = game(ui, ctx, best)
  5830.         if secs and (best == 0 or secs < best) then
  5831.             best = secs
  5832.             ctx.save("mines_best", best)
  5833.         end
  5834.         if action ~= "again" then return end
  5835.     end
  5836. end
  5837.  
  5838. return M
  5839. ]==],
  5840. ["pocketos/apps/games/tetris.lua"] = [==[
  5841. -- PocketOS app: Tetris.
  5842. -- Keys: arrows/WASD move + rotate, Space hard drops.
  5843. -- Touch: tap left/right third of the board to move, middle to rotate,
  5844. -- bottom rows to hard drop.
  5845.  
  5846. local M = {}
  5847.  
  5848. local BW, BH = 10, 18
  5849.  
  5850. local PIECES = {
  5851.     { c = colors.cyan,   m = { { 0, 0, 0, 0 }, { 1, 1, 1, 1 }, { 0, 0, 0, 0 }, { 0, 0, 0, 0 } } },
  5852.     { c = colors.yellow, m = { { 1, 1 }, { 1, 1 } } },
  5853.     { c = colors.purple, m = { { 0, 1, 0 }, { 1, 1, 1 }, { 0, 0, 0 } } },
  5854.     { c = colors.green,  m = { { 0, 1, 1 }, { 1, 1, 0 }, { 0, 0, 0 } } },
  5855.     { c = colors.red,    m = { { 1, 1, 0 }, { 0, 1, 1 }, { 0, 0, 0 } } },
  5856.     { c = colors.blue,   m = { { 1, 0, 0 }, { 1, 1, 1 }, { 0, 0, 0 } } },
  5857.     { c = colors.orange, m = { { 0, 0, 1 }, { 1, 1, 1 }, { 0, 0, 0 } } },
  5858. }
  5859.  
  5860. local toBlit = colors.toBlit or function(c)
  5861.     local i, v = 0, c
  5862.     while v > 1 do
  5863.         v = math.floor(v / 2)
  5864.         i = i + 1
  5865.     end
  5866.     return ("0123456789abcdef"):sub(i + 1, i + 1)
  5867. end
  5868.  
  5869. local function rot(m)
  5870.     local n = #m
  5871.     local r = {}
  5872.     for y = 1, n do
  5873.         r[y] = {}
  5874.         for x = 1, n do
  5875.             r[y][x] = m[n - x + 1][y]
  5876.         end
  5877.     end
  5878.     return r
  5879. end
  5880.  
  5881. local function game(ui, ctx, best)
  5882.     local x0, y0 = 2, 2
  5883.     local board = {}
  5884.     for y = 1, BH do board[y] = {} end
  5885.  
  5886.     local score, lines, level = 0, 0, 1
  5887.     local cur, nxt
  5888.  
  5889.     local function newPiece()
  5890.         local p = PIECES[math.random(#PIECES)]
  5891.         return { m = p.m, c = p.c, x = math.floor((BW - #p.m) / 2) + 1, y = 0 }
  5892.     end
  5893.  
  5894.     local function fits(m, px, py)
  5895.         for j = 1, #m do
  5896.             for i = 1, #m do
  5897.                 if m[j][i] == 1 then
  5898.                     local bx, by = px + i - 1, py + j - 1
  5899.                     if bx < 1 or bx > BW or by > BH then return false end
  5900.                     if by >= 1 and board[by][bx] then return false end
  5901.                 end
  5902.             end
  5903.         end
  5904.         return true
  5905.     end
  5906.  
  5907.     local function dropY(p)
  5908.         local y = p.y
  5909.         while fits(p.m, p.x, y + 1) do y = y + 1 end
  5910.         return y
  5911.     end
  5912.  
  5913.     local function drawBoard()
  5914.         local gy = cur and dropY(cur)
  5915.         for y = 1, BH do
  5916.             local txt, fg, bg = {}, {}, {}
  5917.             for x = 1, BW do
  5918.                 local c = board[y][x]
  5919.                 if not c and cur and gy then
  5920.                     local i, j = x - cur.x + 1, y - gy + 1
  5921.                     if i >= 1 and i <= #cur.m and j >= 1 and j <= #cur.m
  5922.                         and cur.m[j][i] == 1 then
  5923.                         c = colors.lightGray -- ghost landing preview
  5924.                     end
  5925.                 end
  5926.                 if cur then
  5927.                     local i, j = x - cur.x + 1, y - cur.y + 1
  5928.                     if i >= 1 and i <= #cur.m and j >= 1 and j <= #cur.m
  5929.                         and cur.m[j][i] == 1 then
  5930.                         c = cur.c
  5931.                     end
  5932.                 end
  5933.                 local h = toBlit(c or colors.white)
  5934.                 txt[#txt + 1] = "  "
  5935.                 fg[#fg + 1] = h .. h
  5936.                 bg[#bg + 1] = h .. h
  5937.             end
  5938.             term.setCursorPos(x0, y0 + y - 1)
  5939.             term.blit(table.concat(txt), table.concat(fg), table.concat(bg))
  5940.         end
  5941.     end
  5942.  
  5943.     local function drawPanel()
  5944.         ui.fill(23, 2, ui.w - 22, BH, colors.white)
  5945.         ui.text(23, 2, "Next", colors.gray, colors.white)
  5946.         if nxt then
  5947.             for j = 1, #nxt.m do
  5948.                 for i = 1, #nxt.m do
  5949.                     if nxt.m[j][i] == 1 then
  5950.                         ui.fill(22 + i, 3 + j, 1, 1, nxt.c)
  5951.                     end
  5952.                 end
  5953.             end
  5954.         end
  5955.         ui.text(23, 9, "Pts", colors.gray, colors.white)
  5956.         local st = score > 9999 and (math.floor(score / 1000) .. "k") or tostring(score)
  5957.         ui.text(23, 10, st, colors.black, colors.white)
  5958.         ui.text(23, 12, "Lns", colors.gray, colors.white)
  5959.         ui.text(23, 13, tostring(lines), colors.black, colors.white)
  5960.         ui.text(23, 15, "Lvl", colors.gray, colors.white)
  5961.         ui.text(23, 16, tostring(level), colors.black, colors.white)
  5962.     end
  5963.  
  5964.     local function lock()
  5965.         for j = 1, #cur.m do
  5966.             for i = 1, #cur.m do
  5967.                 if cur.m[j][i] == 1 then
  5968.                     local bx, by = cur.x + i - 1, cur.y + j - 1
  5969.                     if by < 1 then return false end
  5970.                     board[by][bx] = cur.c
  5971.                 end
  5972.             end
  5973.         end
  5974.         local cleared = 0
  5975.         local y = BH
  5976.         while y >= 1 do
  5977.             local full = true
  5978.             for x = 1, BW do
  5979.                 if not board[y][x] then full = false; break end
  5980.             end
  5981.             if full then
  5982.                 table.remove(board, y)
  5983.                 table.insert(board, 1, {})
  5984.                 cleared = cleared + 1
  5985.             else
  5986.                 y = y - 1
  5987.             end
  5988.         end
  5989.         if cleared > 0 then
  5990.             lines = lines + cleared
  5991.             score = score + ({ 100, 300, 500, 800 })[cleared] * level
  5992.             level = math.floor(lines / 10) + 1
  5993.         end
  5994.         return true
  5995.     end
  5996.  
  5997.     local interval = 0.5
  5998.  
  5999.     local function settle()
  6000.         if not lock() then return false end
  6001.         cur = nxt
  6002.         nxt = newPiece()
  6003.         interval = math.max(0.1, 0.5 - (level - 1) * 0.05)
  6004.         drawPanel()
  6005.         return fits(cur.m, cur.x, cur.y)
  6006.     end
  6007.  
  6008.     local function gameOver()
  6009.         local pick = ui.dialog("Game Over",
  6010.             { "Score: " .. score, "Best: " .. math.max(best, score) },
  6011.             { { "Again", colors.blue }, { "Home", colors.gray } })
  6012.         return pick == 1 and "again" or "home"
  6013.     end
  6014.  
  6015.     local function tryRotate()
  6016.         local m2 = rot(cur.m)
  6017.         for _, k in ipairs({ 0, -1, 1, -2, 2 }) do
  6018.             if fits(m2, cur.x + k, cur.y) then
  6019.                 cur.m = m2
  6020.                 cur.x = cur.x + k
  6021.                 drawBoard()
  6022.                 return
  6023.             end
  6024.         end
  6025.     end
  6026.  
  6027.     ui.clear(colors.white)
  6028.     ui.topBar("Tetris")
  6029.     ui.fill(1, 2, 1, BH, colors.lightGray)
  6030.     ui.fill(22, 2, 1, BH, colors.lightGray)
  6031.     ui.center(ui.h, "\27\26 move \24 spin \25 drop", colors.gray, colors.white)
  6032.  
  6033.     cur = newPiece()
  6034.     nxt = newPiece()
  6035.     drawBoard()
  6036.     drawPanel()
  6037.     local grav = os.startTimer(interval)
  6038.  
  6039.     local function hardDrop()
  6040.         local y = dropY(cur)
  6041.         score = score + (y - cur.y) * 2
  6042.         cur.y = y
  6043.         if not settle() then return false end
  6044.         drawBoard()
  6045.         grav = os.startTimer(interval)
  6046.         return true
  6047.     end
  6048.  
  6049.     while true do
  6050.         local e, a, b, c = os.pullEvent()
  6051.         if e == "timer" and a == grav then
  6052.             if fits(cur.m, cur.x, cur.y + 1) then
  6053.                 cur.y = cur.y + 1
  6054.             elseif not settle() then
  6055.                 return gameOver(), score
  6056.             end
  6057.             drawBoard()
  6058.             grav = os.startTimer(interval)
  6059.         elseif e == "key" then
  6060.             if a == keys.left or a == keys.a then
  6061.                 if fits(cur.m, cur.x - 1, cur.y) then
  6062.                     cur.x = cur.x - 1
  6063.                     drawBoard()
  6064.                 end
  6065.             elseif a == keys.right or a == keys.d then
  6066.                 if fits(cur.m, cur.x + 1, cur.y) then
  6067.                     cur.x = cur.x + 1
  6068.                     drawBoard()
  6069.                 end
  6070.             elseif a == keys.up or a == keys.w then
  6071.                 tryRotate()
  6072.             elseif a == keys.down or a == keys.s then
  6073.                 if fits(cur.m, cur.x, cur.y + 1) then
  6074.                     cur.y = cur.y + 1
  6075.                     score = score + 1
  6076.                     drawBoard()
  6077.                 end
  6078.             elseif a == keys.space then
  6079.                 if not hardDrop() then return gameOver(), score end
  6080.             end
  6081.         elseif e == "mouse_click" then
  6082.             if ui.isBack(b, c) then return "home", score end
  6083.             if b >= x0 and b <= x0 + BW * 2 - 1 and c >= 2 and c <= BH + 1 then
  6084.                 local bx = math.floor((b - x0) / 2) + 1
  6085.                 if c >= 16 then
  6086.                     if not hardDrop() then return gameOver(), score end
  6087.                 elseif bx <= 3 then
  6088.                     if fits(cur.m, cur.x - 1, cur.y) then
  6089.                         cur.x = cur.x - 1
  6090.                         drawBoard()
  6091.                     end
  6092.                 elseif bx >= 8 then
  6093.                     if fits(cur.m, cur.x + 1, cur.y) then
  6094.                         cur.x = cur.x + 1
  6095.                         drawBoard()
  6096.                     end
  6097.                 else
  6098.                     tryRotate()
  6099.                 end
  6100.             end
  6101.         end
  6102.     end
  6103. end
  6104.  
  6105. function M.run(ui, ctx)
  6106.     local best = ctx.load("tetris_best", 0)
  6107.     while true do
  6108.         local action, score = game(ui, ctx, best)
  6109.         if score and score > best then
  6110.             best = score
  6111.             ctx.save("tetris_best", best)
  6112.         end
  6113.         if action ~= "again" then return end
  6114.     end
  6115. end
  6116.  
  6117. return M
  6118. ]==],
  6119. ["pocketos/apps/games/solitaire.lua"] = [==[
  6120. -- PocketOS game: Cards - Klondike solitaire.
  6121. -- Tap the deck to draw a card. Tap a face-up card to pick it up (cards
  6122. -- lower in a pile grab the whole stack), then tap the destination pile
  6123. -- or a foundation. Tapping a selected top card again auto-sends it to
  6124. -- a foundation when it fits. Face-down cards show as a grey counter.
  6125.  
  6126. local M = {}
  6127.  
  6128. local SUITC = { "\3", "\4", "\5", "\6" } -- hearts diamonds clubs spades
  6129. local RANKC = { "A", "2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K" }
  6130.  
  6131. local function isRed(s) return s <= 2 end
  6132.  
  6133. local function game(ui)
  6134.     local deck = {}
  6135.     for s = 1, 4 do
  6136.         for r = 1, 13 do
  6137.             deck[#deck + 1] = { r = r, s = s, up = false }
  6138.         end
  6139.     end
  6140.     for i = #deck, 2, -1 do
  6141.         local j = math.random(i)
  6142.         deck[i], deck[j] = deck[j], deck[i]
  6143.     end
  6144.  
  6145.     local tab = {}
  6146.     for i = 1, 7 do
  6147.         tab[i] = {}
  6148.         for k = 1, i do
  6149.             local card = table.remove(deck)
  6150.             card.up = (k == i)
  6151.             tab[i][k] = card
  6152.         end
  6153.     end
  6154.     local stock, waste = deck, {}
  6155.     local found = { {}, {}, {}, {} }
  6156.     local sel = nil -- { t = "waste" } or { t = "tab", p = pile, i = index }
  6157.  
  6158.     local function pileX(i) return 3 * i end
  6159.  
  6160.     local function downCount(pile)
  6161.         local n = 0
  6162.         for _, c in ipairs(pile) do
  6163.             if c.up then break end
  6164.             n = n + 1
  6165.         end
  6166.         return n
  6167.     end
  6168.  
  6169.     local function drawCard(x, y, c, hl)
  6170.         term.setCursorPos(x, y)
  6171.         if not c then
  6172.             term.setBackgroundColor(colors.white)
  6173.             term.setTextColor(colors.lightGray)
  6174.             term.write("--")
  6175.         elseif not c.up then
  6176.             term.setBackgroundColor(colors.lightGray)
  6177.             term.write("  ")
  6178.         else
  6179.             term.setBackgroundColor(hl and colors.blue or colors.white)
  6180.             term.setTextColor(hl and colors.white
  6181.                 or (isRed(c.s) and colors.red or colors.black))
  6182.             term.write(RANKC[c.r] .. SUITC[c.s])
  6183.         end
  6184.     end
  6185.  
  6186.     local function drawAll()
  6187.         ui.clear(colors.white)
  6188.         ui.topBar("Cards", "+", colors.blue)
  6189.         term.setCursorPos(3, 3)
  6190.         if #stock > 0 then
  6191.             term.setBackgroundColor(colors.green)
  6192.             term.write("  ")
  6193.         else
  6194.             term.setBackgroundColor(colors.white)
  6195.             term.setTextColor(colors.gray)
  6196.             term.write("O ")
  6197.         end
  6198.         drawCard(6, 3, waste[#waste], sel and sel.t == "waste")
  6199.         for f = 1, 4 do
  6200.             drawCard(9 + 3 * f, 3, found[f][#found[f]])
  6201.         end
  6202.         for i = 1, 7 do
  6203.             local pile = tab[i]
  6204.             local downs = downCount(pile)
  6205.             local y = 5
  6206.             if #pile == 0 then
  6207.                 drawCard(pileX(i), y, nil)
  6208.             else
  6209.                 if downs > 0 then
  6210.                     term.setCursorPos(pileX(i), y)
  6211.                     term.setBackgroundColor(colors.lightGray)
  6212.                     term.setTextColor(colors.gray)
  6213.                     term.write(downs .. " ")
  6214.                     y = y + 1
  6215.                 end
  6216.                 for k = downs + 1, #pile do
  6217.                     drawCard(pileX(i), y, pile[k],
  6218.                         sel and sel.t == "tab" and sel.p == i and k >= sel.i)
  6219.                     y = y + 1
  6220.                 end
  6221.             end
  6222.         end
  6223.         ui.center(ui.h, "Drag cards or tap-tap", colors.lightGray, colors.white)
  6224.     end
  6225.  
  6226.     local function canTab(card, pile)
  6227.         if #pile == 0 then return card.r == 13 end
  6228.         local top = pile[#pile]
  6229.         return top.up and top.r == card.r + 1 and isRed(top.s) ~= isRed(card.s)
  6230.     end
  6231.  
  6232.     local function canFound(card, f)
  6233.         local pile = found[f]
  6234.         if #pile == 0 then return card.r == 1 end
  6235.         local top = pile[#pile]
  6236.         return top.s == card.s and card.r == top.r + 1
  6237.     end
  6238.  
  6239.     local function selSource()
  6240.         if not sel then return nil end
  6241.         if sel.t == "waste" then return waste, #waste end
  6242.         return tab[sel.p], sel.i
  6243.     end
  6244.  
  6245.     local function afterMove()
  6246.         if sel and sel.t == "tab" then
  6247.             local p = tab[sel.p]
  6248.             if #p > 0 and not p[#p].up then p[#p].up = true end
  6249.         end
  6250.         sel = nil
  6251.     end
  6252.  
  6253.     local function toFoundation(f)
  6254.         local src, si = selSource()
  6255.         if not src or si ~= #src then return false end
  6256.         local card = src[si]
  6257.         if not card or not canFound(card, f) then return false end
  6258.         found[f][#found[f] + 1] = table.remove(src)
  6259.         afterMove()
  6260.         return true
  6261.     end
  6262.  
  6263.     local function autoFoundation()
  6264.         for f = 1, 4 do
  6265.             if toFoundation(f) then return true end
  6266.         end
  6267.         return false
  6268.     end
  6269.  
  6270.     local function toTableau(i)
  6271.         local src, si = selSource()
  6272.         local card = src and src[si]
  6273.         if not card then return false end
  6274.         if sel.t == "tab" and sel.p == i then return false end
  6275.         if not canTab(card, tab[i]) then return false end
  6276.         for k = si, #src do
  6277.             tab[i][#tab[i] + 1] = src[k]
  6278.         end
  6279.         for _ = si, #src do
  6280.             table.remove(src)
  6281.         end
  6282.         afterMove()
  6283.         return true
  6284.     end
  6285.  
  6286.     local function won()
  6287.         return #found[1] + #found[2] + #found[3] + #found[4] == 52
  6288.     end
  6289.  
  6290.     -- what's under a screen position (generous hit bands)
  6291.     local function zoneAt(mx, my)
  6292.         if my >= 2 and my <= 4 then
  6293.             if mx >= 3 and mx <= 5 then return "stock" end
  6294.             if mx >= 6 and mx <= 8 then return "waste" end
  6295.             if mx >= 12 and mx <= 23 then
  6296.                 local rel = mx - 12
  6297.                 if rel % 3 <= 1 then return "found", math.floor(rel / 3) + 1 end
  6298.             end
  6299.         elseif my >= 5 then
  6300.             local i = math.floor(mx / 3)
  6301.             if i >= 1 and i <= 7 then return "tab", i end
  6302.         end
  6303.     end
  6304.  
  6305.     local function selectAt(i, my)
  6306.         local pile = tab[i]
  6307.         local downs = downCount(pile)
  6308.         if #pile <= downs then
  6309.             sel = nil
  6310.             return
  6311.         end
  6312.         local firstUp = 5 + (downs > 0 and 1 or 0)
  6313.         local idx = downs + (my - firstUp + 1)
  6314.         if idx < downs + 1 then idx = downs + 1 end
  6315.         if idx > #pile then idx = #pile end
  6316.         sel = { t = "tab", p = i, i = idx }
  6317.     end
  6318.  
  6319.     drawAll()
  6320.     while true do
  6321.         local e, _, mx, my = os.pullEvent()
  6322.         local changed = false
  6323.         if e == "mouse_click" then
  6324.             if ui.isBack(mx, my) then return "home" end
  6325.             if my == 1 and mx >= ui.w - 2 then return "again" end -- new deal
  6326.             local zone, zi = zoneAt(mx, my)
  6327.             if zone == "stock" then
  6328.                 sel = nil
  6329.                 if #stock > 0 then
  6330.                     local c = table.remove(stock)
  6331.                     c.up = true
  6332.                     waste[#waste + 1] = c
  6333.                 else
  6334.                     for _ = 1, #waste do
  6335.                         local c = table.remove(waste)
  6336.                         c.up = false
  6337.                         stock[#stock + 1] = c
  6338.                     end
  6339.                 end
  6340.                 changed = true
  6341.             elseif zone == "waste" then
  6342.                 if sel and sel.t == "waste" then
  6343.                     if not autoFoundation() then sel = nil end
  6344.                 elseif #waste > 0 then
  6345.                     sel = { t = "waste" }
  6346.                 end
  6347.                 changed = true
  6348.             elseif zone == "found" then
  6349.                 if sel then toFoundation(zi) end
  6350.                 changed = true
  6351.             elseif zone == "tab" then
  6352.                 if sel then
  6353.                     if sel.t == "tab" and sel.p == zi then
  6354.                         local downs = downCount(tab[zi])
  6355.                         local firstUp = 5 + (downs > 0 and 1 or 0)
  6356.                         local idx = downs + (my - firstUp + 1)
  6357.                         if idx == sel.i and sel.i == #tab[zi] then
  6358.                             autoFoundation()
  6359.                         else
  6360.                             selectAt(zi, my)
  6361.                         end
  6362.                     elseif not toTableau(zi) then
  6363.                         -- couldn't move there: pick that card up instead
  6364.                         selectAt(zi, my)
  6365.                     end
  6366.                 else
  6367.                     selectAt(zi, my)
  6368.                 end
  6369.                 changed = true
  6370.             end
  6371.         elseif e == "mouse_up" and sel then
  6372.             -- drag-and-drop: release over a different pile moves there
  6373.             local zone, zi = zoneAt(mx, my)
  6374.             if zone == "tab" and not (sel.t == "tab" and sel.p == zi) then
  6375.                 if toTableau(zi) then changed = true end
  6376.             elseif zone == "found" then
  6377.                 if toFoundation(zi) then changed = true end
  6378.             end
  6379.         end
  6380.         if changed then
  6381.             drawAll()
  6382.             if won() then
  6383.                 local pick = ui.dialog("You Win!", { "All 52 cards home!" },
  6384.                     { { "Again", colors.blue }, { "Home", colors.gray } })
  6385.                 return pick == 1 and "again" or "home"
  6386.             end
  6387.         end
  6388.     end
  6389. end
  6390.  
  6391. function M.run(ui, ctx)
  6392.     while true do
  6393.         if game(ui) ~= "again" then return end
  6394.     end
  6395. end
  6396.  
  6397. return M
  6398. ]==],
  6399. ["pocketos/apps/games/flappy.lua"] = [==[
  6400. -- PocketOS game: Flappy - tap or press Space to flap through the pipes.
  6401.  
  6402. local M = {}
  6403.  
  6404. local toBlit = colors.toBlit or function(c)
  6405.     local i, v = 0, c
  6406.     while v > 1 do
  6407.         v = math.floor(v / 2)
  6408.         i = i + 1
  6409.     end
  6410.     return ("0123456789abcdef"):sub(i + 1, i + 1)
  6411. end
  6412.  
  6413. local function game(ui, best)
  6414.     local top, bottom = 2, ui.h - 2
  6415.     local gapH = 6
  6416.     local bx = 7
  6417.     local by, vy = 9, 0
  6418.     local pipes = {}
  6419.     local score = 0
  6420.     local dist = 0
  6421.     local dead = false
  6422.  
  6423.     ui.clear(colors.white)
  6424.     ui.topBar("Flappy", string.format("%4d", 0))
  6425.     ui.fill(1, ui.h - 1, ui.w, 1, colors.gray)
  6426.     ui.center(ui.h, "Tap or Space to flap", colors.gray, colors.white)
  6427.  
  6428.     local WB = toBlit(colors.white)
  6429.     local GB = toBlit(colors.green)
  6430.     local YB = toBlit(colors.yellow)
  6431.     local KB = toBlit(colors.black)
  6432.     local RB = toBlit(colors.red)
  6433.  
  6434.     local function drawFrame()
  6435.         local birdRow = math.max(top, math.min(bottom, math.floor(by + 0.5)))
  6436.         for y = top, bottom do
  6437.             local txt, fg, bg = {}, {}, {}
  6438.             for x = 1, ui.w do
  6439.                 local ch, f, b = " ", WB, WB
  6440.                 for _, p in ipairs(pipes) do
  6441.                     if (x == p.x or x == p.x + 1)
  6442.                         and (y < p.gap or y >= p.gap + gapH) then
  6443.                         f, b = GB, GB
  6444.                     end
  6445.                 end
  6446.                 if x == bx and y == birdRow then
  6447.                     ch = ">"
  6448.                     f = KB
  6449.                     b = dead and RB or YB
  6450.                 end
  6451.                 txt[#txt + 1], fg[#fg + 1], bg[#bg + 1] = ch, f, b
  6452.             end
  6453.             term.setCursorPos(1, y)
  6454.             term.blit(table.concat(txt), table.concat(fg), table.concat(bg))
  6455.         end
  6456.     end
  6457.  
  6458.     drawFrame()
  6459.  
  6460.     -- wait for the first flap
  6461.     while true do
  6462.         local e, a, b, c = os.pullEvent()
  6463.         if e == "key" and (a == keys.space or a == keys.up or a == keys.w) then
  6464.             vy = -1.15
  6465.             break
  6466.         elseif e == "mouse_click" then
  6467.             if ui.isBack(b, c) then return "home", score end
  6468.             vy = -1.15
  6469.             break
  6470.         end
  6471.     end
  6472.  
  6473.     local timer = os.startTimer(0.12)
  6474.     while true do
  6475.         local e, a, b, c = os.pullEvent()
  6476.         if e == "timer" and a == timer then
  6477.             dist = dist + 1
  6478.             if dist % 11 == 0 then
  6479.                 pipes[#pipes + 1] = {
  6480.                     x = ui.w,
  6481.                     gap = math.random(top + 1, bottom - gapH),
  6482.                 }
  6483.             end
  6484.             for i = #pipes, 1, -1 do
  6485.                 local p = pipes[i]
  6486.                 p.x = p.x - 1
  6487.                 if not p.passed and p.x + 1 < bx then
  6488.                     p.passed = true
  6489.                     score = score + 1
  6490.                     ui.text(ui.w - 4, 1, string.format("%4d", score),
  6491.                         colors.gray, colors.white)
  6492.                 end
  6493.                 if p.x + 1 < 1 then table.remove(pipes, i) end
  6494.             end
  6495.             vy = vy + 0.35
  6496.             by = by + vy
  6497.             if by < top then
  6498.                 by = top
  6499.                 vy = 0
  6500.             end
  6501.             local birdRow = math.floor(by + 0.5)
  6502.             local hit = birdRow > bottom
  6503.             if not hit then
  6504.                 for _, p in ipairs(pipes) do
  6505.                     if (bx == p.x or bx == p.x + 1)
  6506.                         and (birdRow < p.gap or birdRow >= p.gap + gapH) then
  6507.                         hit = true
  6508.                         break
  6509.                     end
  6510.                 end
  6511.             end
  6512.             if hit then
  6513.                 dead = true
  6514.                 by = math.min(by, bottom)
  6515.                 drawFrame()
  6516.                 sleep(0.4)
  6517.                 local pick = ui.dialog("Game Over",
  6518.                     { "Score: " .. score, "Best: " .. math.max(best, score) },
  6519.                     { { "Again", colors.blue }, { "Home", colors.gray } })
  6520.                 return (pick == 1 and "again" or "home"), score
  6521.             end
  6522.             drawFrame()
  6523.             timer = os.startTimer(0.12)
  6524.         elseif e == "key" and (a == keys.space or a == keys.up or a == keys.w) then
  6525.             vy = -1.15
  6526.         elseif e == "mouse_click" then
  6527.             if ui.isBack(b, c) then return "home", score end
  6528.             vy = -1.15
  6529.         end
  6530.     end
  6531. end
  6532.  
  6533. function M.run(ui, ctx)
  6534.     local best = ctx.load("flappy_best", 0)
  6535.     while true do
  6536.         local action, score = game(ui, best)
  6537.         if score and score > best then
  6538.             best = score
  6539.             ctx.save("flappy_best", best)
  6540.         end
  6541.         if action ~= "again" then return end
  6542.     end
  6543. end
  6544.  
  6545. return M
  6546. ]==],
  6547. ["pocketos/apps/games/sudoku.lua"] = [==[
  6548. -- PocketOS game: Sudoku. A fresh puzzle every game.
  6549. -- Tap a cell (or move with arrows), then tap the number pad or type
  6550. -- 1-9. X, 0 or Backspace erases. Wrong numbers show up red.
  6551.  
  6552. local M = {}
  6553.  
  6554. local function newPuzzle()
  6555.     local g = {}
  6556.     for y = 1, 9 do g[y] = {} end
  6557.     local function ok(y, x, v)
  6558.         for i = 1, 9 do
  6559.             if g[y][i] == v or g[i][x] == v then return false end
  6560.         end
  6561.         local by = math.floor((y - 1) / 3) * 3
  6562.         local bx = math.floor((x - 1) / 3) * 3
  6563.         for yy = by + 1, by + 3 do
  6564.             for xx = bx + 1, bx + 3 do
  6565.                 if g[yy][xx] == v then return false end
  6566.             end
  6567.         end
  6568.         return true
  6569.     end
  6570.     local function fill(pos)
  6571.         if pos > 81 then return true end
  6572.         local y = math.floor((pos - 1) / 9) + 1
  6573.         local x = (pos - 1) % 9 + 1
  6574.         local vals = { 1, 2, 3, 4, 5, 6, 7, 8, 9 }
  6575.         for i = 9, 2, -1 do
  6576.             local j = math.random(i)
  6577.             vals[i], vals[j] = vals[j], vals[i]
  6578.         end
  6579.         for _, v in ipairs(vals) do
  6580.             if ok(y, x, v) then
  6581.                 g[y][x] = v
  6582.                 if fill(pos + 1) then return true end
  6583.                 g[y][x] = nil
  6584.             end
  6585.         end
  6586.         return false
  6587.     end
  6588.     fill(1)
  6589.  
  6590.     local given = {}
  6591.     for y = 1, 9 do
  6592.         given[y] = {}
  6593.         for x = 1, 9 do given[y][x] = true end
  6594.     end
  6595.     local removed = 0
  6596.     while removed < 45 do
  6597.         local y, x = math.random(9), math.random(9)
  6598.         if given[y][x] then
  6599.             given[y][x] = false
  6600.             removed = removed + 1
  6601.         end
  6602.     end
  6603.     local val = {}
  6604.     for y = 1, 9 do
  6605.         val[y] = {}
  6606.         for x = 1, 9 do
  6607.             if given[y][x] then val[y][x] = g[y][x] end
  6608.         end
  6609.     end
  6610.     return val, given
  6611. end
  6612.  
  6613. local function game(ui, ctx, best)
  6614.     local val, given = newPuzzle()
  6615.     local x0, y0 = 5, 4
  6616.     local sx, sy = 5, 5
  6617.     local secs = 0
  6618.     local clock = os.startTimer(1)
  6619.  
  6620.     local function conflict(y, x)
  6621.         local v = val[y][x]
  6622.         if not v then return false end
  6623.         for i = 1, 9 do
  6624.             if i ~= x and val[y][i] == v then return true end
  6625.             if i ~= y and val[i][x] == v then return true end
  6626.         end
  6627.         local by = math.floor((y - 1) / 3) * 3
  6628.         local bx = math.floor((x - 1) / 3) * 3
  6629.         for yy = by + 1, by + 3 do
  6630.             for xx = bx + 1, bx + 3 do
  6631.                 if (yy ~= y or xx ~= x) and val[yy][xx] == v then return true end
  6632.             end
  6633.         end
  6634.         return false
  6635.     end
  6636.  
  6637.     local function drawCell(x, y)
  6638.         local box = (math.floor((x - 1) / 3) + math.floor((y - 1) / 3)) % 2
  6639.         local bg = (x == sx and y == sy) and colors.yellow
  6640.             or (box == 0 and colors.white or colors.lightGray)
  6641.         local v = val[y][x]
  6642.         local fg
  6643.         if v and conflict(y, x) then
  6644.             fg = colors.red
  6645.         elseif given[y][x] then
  6646.             fg = colors.black
  6647.         else
  6648.             fg = colors.blue
  6649.         end
  6650.         ui.text(x0 + (x - 1) * 2, y0 + y - 1, (v and tostring(v) or " ") .. " ", fg, bg)
  6651.     end
  6652.  
  6653.     local function drawBoard()
  6654.         for y = 1, 9 do
  6655.             for x = 1, 9 do drawCell(x, y) end
  6656.         end
  6657.     end
  6658.  
  6659.     local function blanks()
  6660.         local n = 0
  6661.         for y = 1, 9 do
  6662.             for x = 1, 9 do
  6663.                 if not val[y][x] then n = n + 1 end
  6664.             end
  6665.         end
  6666.         return n
  6667.     end
  6668.  
  6669.     local function drawHud()
  6670.         ui.fill(1, 2, ui.w, 1, colors.white)
  6671.         ui.text(2, 2, "Left: " .. blanks(), colors.black, colors.white)
  6672.         local t = string.format("%d:%02d", math.floor(secs / 60), secs % 60)
  6673.         ui.text(ui.w - #t - 1, 2, t, colors.gray, colors.white)
  6674.     end
  6675.  
  6676.     local function drawPad()
  6677.         for i = 1, 10 do
  6678.             local label = i <= 9 and tostring(i) or "X"
  6679.             ui.text(4 + (i - 1) * 2, 15, label .. " ",
  6680.                 i <= 9 and colors.black or colors.red, colors.lightGray)
  6681.         end
  6682.     end
  6683.  
  6684.     local function checkWin()
  6685.         for y = 1, 9 do
  6686.             for x = 1, 9 do
  6687.                 if not val[y][x] or conflict(y, x) then return false end
  6688.             end
  6689.         end
  6690.         return true
  6691.     end
  6692.  
  6693.     local function select(nx, ny)
  6694.         local ox, oy = sx, sy
  6695.         sx, sy = nx, ny
  6696.         drawCell(ox, oy)
  6697.         drawCell(sx, sy)
  6698.     end
  6699.  
  6700.     ui.clear(colors.white)
  6701.     ui.topBar("Sudoku")
  6702.     drawHud()
  6703.     drawBoard()
  6704.     drawPad()
  6705.     ui.center(17, "Tap cell, then number", colors.lightGray, colors.white)
  6706.  
  6707.     while true do
  6708.         local e, a, b, c = os.pullEvent()
  6709.         local placed = nil
  6710.         if e == "timer" and a == clock then
  6711.             secs = math.min(secs + 1, 5999)
  6712.             drawHud()
  6713.             clock = os.startTimer(1)
  6714.         elseif e == "mouse_click" then
  6715.             if ui.isBack(b, c) then return "home" end
  6716.             if c >= y0 and c <= y0 + 8 then
  6717.                 local x = math.floor((b - x0) / 2) + 1
  6718.                 if x >= 1 and x <= 9 then select(x, c - y0 + 1) end
  6719.             elseif c == 15 then
  6720.                 local i = math.floor((b - 4) / 2) + 1
  6721.                 if i >= 1 and i <= 9 then
  6722.                     placed = i
  6723.                 elseif i == 10 then
  6724.                     placed = 0
  6725.                 end
  6726.             end
  6727.         elseif e == "char" then
  6728.             if a:match("^[1-9]$") then
  6729.                 placed = tonumber(a)
  6730.             elseif a == "0" or a:lower() == "x" then
  6731.                 placed = 0
  6732.             end
  6733.         elseif e == "key" then
  6734.             if a == keys.backspace or a == keys.delete then
  6735.                 placed = 0
  6736.             elseif a == keys.up then
  6737.                 select(sx, math.max(1, sy - 1))
  6738.             elseif a == keys.down then
  6739.                 select(sx, math.min(9, sy + 1))
  6740.             elseif a == keys.left then
  6741.                 select(math.max(1, sx - 1), sy)
  6742.             elseif a == keys.right then
  6743.                 select(math.min(9, sx + 1), sy)
  6744.             end
  6745.         end
  6746.         if placed and not given[sy][sx] then
  6747.             val[sy][sx] = placed ~= 0 and placed or nil
  6748.             drawBoard() -- conflicts elsewhere may change too
  6749.             drawHud()
  6750.             if placed ~= 0 and checkWin() then
  6751.                 local lines = { "Solved in " .. secs .. "s" }
  6752.                 if best and best > 0 then lines[2] = "Best: " .. best .. "s" end
  6753.                 local pick = ui.dialog("You Win!", lines,
  6754.                     { { "Again", colors.blue }, { "Home", colors.gray } })
  6755.                 return (pick == 1 and "again" or "home"), secs
  6756.             end
  6757.         end
  6758.     end
  6759. end
  6760.  
  6761. function M.run(ui, ctx)
  6762.     local best = ctx.load("sudoku_best", 0)
  6763.     while true do
  6764.         local action, secs = game(ui, ctx, best)
  6765.         if secs and (best == 0 or secs < best) then
  6766.             best = secs
  6767.             ctx.save("sudoku_best", best)
  6768.         end
  6769.         if action ~= "again" then return end
  6770.     end
  6771. end
  6772.  
  6773. return M
  6774. ]==],
  6775. }
  6776.  
  6777. local CONFIG = "pocketos/config.lua"
  6778.  
  6779. if not (term.isColor and term.isColor()) then
  6780.     print("Warning: PocketOS needs an *advanced*")
  6781.     print("(gold) pocket computer to run.")
  6782.     print("Installing anyway...")
  6783. end
  6784.  
  6785. -- serialize a config value back to Lua source
  6786. local function ser(v)
  6787.     local t = type(v)
  6788.     if t == "string" then return string.format("%q", v) end
  6789.     if t == "number" or t == "boolean" then return tostring(v) end
  6790.     if t == "table" then
  6791.         local parts = {}
  6792.         for _, x in ipairs(v) do parts[#parts + 1] = ser(x) end
  6793.         return "{ " .. table.concat(parts, ", ") .. " }"
  6794.     end
  6795.     return "nil"
  6796. end
  6797.  
  6798. -- The default config shipped with this build (with the maintainer's baked
  6799. -- values). Used to ADD keys a device's config.lua is missing, so new
  6800. -- options appear on update without clobbering the user's existing values.
  6801. local function shippedConfig()
  6802.     local fn = load(files[CONFIG], "config", "t", {})
  6803.     if not fn then return nil end
  6804.     local ok, t = pcall(fn)
  6805.     if ok and type(t) == "table" then return t end
  6806.     return nil
  6807. end
  6808.  
  6809. local function mergeConfig()
  6810.     local rf = fs.open(CONFIG, "r")
  6811.     local text = rf.readAll() or ""
  6812.     rf.close()
  6813.     local shipped = shippedConfig()
  6814.     if not shipped then print("  kept " .. CONFIG); return end
  6815.     local additions = ""
  6816.     for k, v in pairs(shipped) do
  6817.         if type(k) == "string" and not text:find("\n%s*" .. k .. "%s*=") then
  6818.             additions = additions .. "\n    " .. k .. " = " .. ser(v) .. ",\n"
  6819.         end
  6820.     end
  6821.     if additions == "" then print("  kept " .. CONFIG); return end
  6822.     local last
  6823.     for p in text:gmatch("()}") do last = p end
  6824.     if not last then print("  kept " .. CONFIG .. " (no merge point)"); return end
  6825.     text = text:sub(1, last - 1) .. additions .. text:sub(last)
  6826.     local wf = fs.open(CONFIG, "w")
  6827.     wf.write(text)
  6828.     wf.close()
  6829.     print("  merged new keys into " .. CONFIG)
  6830. end
  6831.  
  6832. print("Installing PocketOS...")
  6833. for path, content in pairs(files) do
  6834.     if path == CONFIG and fs.exists(CONFIG) then
  6835.         mergeConfig() -- keep values, add new keys
  6836.     else
  6837.         local dir = fs.getDir(path)
  6838.         if dir ~= "" and not fs.exists(dir) then
  6839.             fs.makeDir(dir)
  6840.         end
  6841.         local f = fs.open(path, "w")
  6842.         f.write(content)
  6843.         f.close()
  6844.         print("  " .. path)
  6845.     end
  6846. end
  6847.  
  6848. -- clean up files from older layouts and removed apps
  6849. for _, old in ipairs({
  6850.     "/pocketos/apps/dash.lua",
  6851.     "/pocketos/apps/snake.lua",
  6852.     "/pocketos/apps/blocks.lua",
  6853.     "/pocketos/apps/match.lua",
  6854.     "/pocketos/apps/mines.lua",
  6855.     "/pocketos/apps/tetris.lua",
  6856. }) do
  6857.     if fs.exists(old) then fs.delete(old) end
  6858. end
  6859.  
  6860. print("Done! Rebooting into PocketOS...")
  6861. sleep(1.5)
  6862. os.reboot()
  6863.  
Advertisement
Add Comment
Please, Sign In to add comment