Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- -- LemonOS 3.0 -- Integrated client & UI with built-in DM system
- -- by ChatGPT (GPT-5)
- -- Features: integrated DMs (phone numbers s-xxxx-xxxx), registration, always-listening chat listener,
- -- beep on incoming messages, unread indicator, signal bars (async), persistent config.
- -- ===== CONFIG PATHS & PROTOCOL =====
- local CONFIG_PATH = "/lemonos.cfg"
- local MESSAGES_PATH = "/lemonos_messages.db"
- local CHAT_PROTOCOL = "lemonos-net"
- -- ===== FIRMWARE INFO =====
- local firmware = {
- device = "Lemonphone",
- version = "LemonOS 3.0"
- }
- -- ===== DEFAULT SETTINGS =====
- local config = {
- username = "User",
- accentColor = colors.yellow,
- bgColor = colors.black,
- rednetSide = "back",
- serverNode = nil, -- preferred server node ID (can be nil; will auto-discover on register)
- phoneNumber = nil -- assigned phone number (persisted after register)
- }
- -- ===== LOAD / SAVE CONFIG & MESSAGES =====
- local function saveConfig()
- local f = fs.open(CONFIG_PATH, "w")
- if not f then return false end
- f.write(textutils.serialize(config))
- f.close()
- return true
- end
- local function loadConfig()
- if fs.exists(CONFIG_PATH) then
- local f = fs.open(CONFIG_PATH, "r")
- if f then
- local ok, t = pcall(textutils.unserialize, f.readAll())
- f.close()
- if ok and type(t) == "table" then
- for k,v in pairs(t) do config[k] = v end
- end
- end
- end
- end
- loadConfig()
- -- optional persistent message store (very small)
- local function saveMessages(tbl)
- local f = fs.open(MESSAGES_PATH, "w")
- if not f then return false end
- f.write(textutils.serialize(tbl))
- f.close()
- return true
- end
- local function loadMessages()
- if fs.exists(MESSAGES_PATH) then
- local f = fs.open(MESSAGES_PATH, "r")
- if f then
- local ok, t = pcall(textutils.unserialize, f.readAll())
- f.close()
- if ok and type(t) == "table" then return t end
- end
- end
- return {}
- end
- -- ===== GLOBAL STATE =====
- local messages = loadMessages() -- { {from=phone, to=phone, text=..., ts=...}, ... }
- local unreadCount = 0
- local unreadIndicator = nil -- {from, text, expires}
- local signalStrength = 0 -- 0..3
- local myPhoneNumber = config.phoneNumber -- convenience local
- local clientMsgCounter = 0
- -- ===== UTILITIES =====
- local function mkClientID()
- clientMsgCounter = clientMsgCounter + 1
- return tostring(os.getComputerID()) .. "-" .. tostring(os.time()) .. "-" .. tostring(clientMsgCounter)
- end
- local function centerText(y, txt, clr)
- local w = term.getSize()
- local x = math.floor((w - #txt) / 2) + 1
- term.setCursorPos(x, y)
- if clr then term.setTextColor(clr) end
- term.write(txt)
- term.setTextColor(colors.white)
- end
- local function beep()
- -- try speaker peripheral
- local ok, s = pcall(function() return peripheral.find("speaker") end)
- if ok and s and type(s.playSound) == "function" then
- pcall(function() s.playSound("note.harp", 1, 1) end)
- return
- end
- -- fallback: BEL character (may beep in some terminals)
- pcall(function() io.write("\7") end)
- end
- -- ===== SIGNAL (ASYNC) =====
- local function distanceToBars(distance)
- if distance <= 5 then return 3
- elseif distance <= 20 then return 2
- else return 1 end
- end
- local function updateSignal()
- while true do
- local strength = 0
- if rednet.isOpen(config.rednetSide) then
- local ids = rednet.lookup("lemonphone_ping") or {}
- local peers = {}
- for _, id in ipairs(ids) do
- -- try a non-fatal send to see if reachable quickly
- local ok = pcall(rednet.send, id, textutils.serialize({ ping=true }), CHAT_PROTOCOL)
- if ok then
- table.insert(peers, math.abs(os.getComputerID() - id))
- end
- end
- if #peers > 0 then
- table.sort(peers)
- strength = distanceToBars(peers[1])
- end
- end
- signalStrength = strength
- sleep(5)
- end
- end
- local function drawSignalBarsInline()
- local barChar = "|"
- for i = 1, 3 do
- if i <= signalStrength then term.setTextColor(colors.lime) else term.setTextColor(colors.gray) end
- term.write(barChar)
- end
- term.setTextColor(colors.white)
- end
- -- ===== TOP BAR DRAW =====
- local function drawTopBar()
- local w = term.getSize()
- -- draw full background of top line with accent color
- term.setCursorPos(1,1)
- term.setBackgroundColor(config.accentColor)
- term.setTextColor(colors.white)
- local blank = string.rep(" ", w)
- term.write(blank)
- -- left: signal + label
- term.setCursorPos(2,1)
- if rednet.isOpen(config.rednetSide) then
- drawSignalBarsInline()
- term.write(" Net")
- else
- term.setTextColor(colors.red)
- term.write("X NoNet")
- term.setTextColor(colors.white)
- end
- -- right: time/date
- local timeStr = textutils.formatTime(os.time(), true)
- local dateStr = os.date("%b %d")
- local clock = timeStr .. " | " .. dateStr
- term.setCursorPos(w - #clock - 1, 1)
- term.setTextColor(colors.white)
- term.write(clock)
- -- unread badge (top-right, just left of clock if any)
- if unreadCount > 0 then
- local badge = "[" .. tostring(unreadCount) .. "]"
- term.setCursorPos(w - #clock - #badge - 3, 1)
- term.setTextColor(colors.red)
- term.write(badge)
- term.setTextColor(colors.white)
- end
- term.setBackgroundColor(config.bgColor)
- term.setTextColor(colors.white)
- end
- -- ===== CHAT: register, send, process incoming =====
- local function sendToNode(node, tbl)
- local ok, ser = pcall(textutils.serialize, tbl)
- if not ok then return false end
- rednet.send(node, ser, CHAT_PROTOCOL)
- return true
- end
- -- Broadcast registration request and wait a short time for a server to respond
- local function registerWithAnyServer(serviceType)
- serviceType = tostring(serviceType or "2")
- local req = {
- msgType = "register_req",
- id = mkClientID(),
- serviceType = serviceType,
- clientInfo = { nodeID = os.getComputerID(), username = config.username },
- timestamp = os.time()
- }
- -- broadcast and wait
- local ok = pcall(rednet.broadcast, textutils.serialize(req), CHAT_PROTOCOL)
- if not ok then return false, "broadcast-failed" end
- local sender, raw = rednet.receive(CHAT_PROTOCOL, 5)
- if not sender or not raw then return false, "no-response" end
- local ok2, tbl = pcall(textutils.unserialize, raw)
- if not ok2 or type(tbl) ~= "table" then return false, "bad-response" end
- if tbl.msgType == "register_res" and tbl.inReplyTo == req.id and tbl.assigned then
- myPhoneNumber = tbl.assigned
- config.phoneNumber = tbl.assigned
- config.serverNode = sender
- saveConfig()
- return true
- end
- return false, "unexpected-response"
- end
- -- direct register to configured server (if server node set)
- local function registerWithServerNode(node, serviceType)
- serviceType = tostring(serviceType or "2")
- local req = {
- msgType = "register_req",
- id = mkClientID(),
- serviceType = serviceType,
- clientInfo = { nodeID = os.getComputerID(), username = config.username },
- timestamp = os.time()
- }
- if not node then return false, "no-node" end
- local ok = sendToNode(tonumber(node), req)
- if not ok then return false, "send-failed" end
- local sender, raw = rednet.receive(CHAT_PROTOCOL, 5)
- if not sender or not raw then return false, "no-response" end
- local ok2, tbl = pcall(textutils.unserialize, raw)
- if ok2 and type(tbl) == "table" and tbl.msgType == "register_res" and tbl.inReplyTo == req.id then
- myPhoneNumber = tbl.assigned
- config.phoneNumber = tbl.assigned
- config.serverNode = node
- saveConfig()
- return true
- end
- return false, "bad-response"
- end
- local function sendDM(toPhone, text)
- if not myPhoneNumber then return false, "not-registered" end
- local node = config.serverNode
- if not node then return false, "no-server" end
- local msg = {
- msgType = "dm",
- id = mkClientID(),
- fromPhone = myPhoneNumber,
- toPhone = toPhone,
- body = { text = tostring(text) },
- ttl = 8,
- path = {},
- timestamp = os.time()
- }
- -- send to server node
- return sendToNode(tonumber(node), msg)
- end
- -- handle incoming chat messages (called from chatListener)
- local function handleIncomingChat(tbl, sender)
- if type(tbl) ~= "table" then return end
- if tbl.msgType == "dm" then
- if tbl.toPhone == myPhoneNumber then
- table.insert(messages, { from = tbl.fromPhone, to = tbl.toPhone, text = tbl.body and tbl.body.text or "", ts = tbl.timestamp or os.time() })
- saveMessages(messages)
- -- queue event for UI/main loop
- os.queueEvent("lemonos_new_message", tbl.fromPhone, tbl.body and tbl.body.text or "")
- end
- elseif tbl.msgType == "register_res" then
- -- could also be handled by register functions which wait for receive
- end
- end
- -- chat listener (background)
- local function chatListener()
- while true do
- local sender, raw = rednet.receive(CHAT_PROTOCOL)
- if sender and raw then
- local ok, tbl = pcall(textutils.unserialize, raw)
- if ok and type(tbl) == "table" then
- handleIncomingChat(tbl, sender)
- end
- end
- end
- end
- -- ===== UI: Menu, Settings, About & Chat UI =====
- local function drawMenu(selected)
- local apps = {
- {name="Messages", cmd="messages"},
- {name="Worm", cmd="worm"},
- {name="Falling Blocks", cmd="falling"},
- {name="Redirection", cmd="redirection"},
- {name="Settings", cmd="settings"},
- {name="About Lemonphone", cmd="about"},
- {name="Shutdown", cmd="shutdown"}
- }
- local startY = 4
- for i, app in ipairs(apps) do
- term.setCursorPos(4, startY + i - 1)
- if i == selected then
- term.setBackgroundColor(colors.gray)
- term.setTextColor(colors.black)
- else
- term.setBackgroundColor(config.bgColor)
- term.setTextColor(colors.white)
- end
- term.clearLine()
- term.setCursorPos(4, startY + i - 1)
- term.write(app.name)
- end
- term.setBackgroundColor(config.bgColor)
- term.setTextColor(colors.white)
- return apps
- end
- local function openSettings()
- while true do
- term.setBackgroundColor(config.bgColor)
- term.clear()
- centerText(3, "Settings", config.accentColor)
- term.setCursorPos(2,5)
- term.write("1. Change Username")
- term.setCursorPos(2,6)
- term.write("2. Change Accent Color")
- term.setCursorPos(2,7)
- term.write("3. Set Server Node")
- term.setCursorPos(2,8)
- term.write("4. Register (auto-discover)")
- term.setCursorPos(2,9)
- term.write("5. Back")
- local _, key = os.pullEvent("key")
- if key == keys.one then
- term.setCursorPos(2,11) term.clearLine() term.write("Enter new username: ")
- config.username = read()
- saveConfig()
- elseif key == keys.two then
- term.setCursorPos(2,11) term.clearLine() term.write("Color name: ")
- local c = read()
- if colors[c] then config.accentColor = colors[c] saveConfig() else term.setCursorPos(2,12) term.write("Invalid color") sleep(1) end
- elseif key == keys.three then
- term.setCursorPos(2,11) term.clearLine() term.write("Enter server node ID (or blank to clear): ")
- local v = read()
- if v == "" then config.serverNode = nil else config.serverNode = tonumber(v) end
- saveConfig()
- elseif key == keys.four then
- term.setCursorPos(2,11) term.clearLine() term.write("Registering (broadcast)...")
- local ok, err = registerWithAnyServer("2")
- term.setCursorPos(2,12)
- if ok then term.write("Registered as " .. tostring(myPhoneNumber)) else term.write("Failed: " .. tostring(err)) end
- sleep(1.5)
- elseif key == keys.five then
- return
- end
- end
- end
- local function aboutPage()
- term.setBackgroundColor(config.bgColor)
- term.clear()
- centerText(3, "About Lemonphone", config.accentColor)
- term.setCursorPos(2,5) term.write("Device: " .. firmware.device)
- term.setCursorPos(2,6) term.write("Firmware: " .. firmware.version)
- term.setCursorPos(2,7) term.write("Phone: " .. tostring(myPhoneNumber or "<unregistered>"))
- term.setCursorPos(2,9) term.write("Press any key to return...")
- os.pullEvent("key")
- end
- local function messagesUI()
- -- simple chat UI: list last 12 messages and an input to send DM
- while true do
- term.setBackgroundColor(config.bgColor)
- term.clear()
- centerText(2, "Messages", config.accentColor)
- local start = math.max(1, #messages - 11)
- local y = 4
- for i = start, #messages do
- local m = messages[i]
- term.setCursorPos(2, y)
- term.clearLine()
- term.setTextColor(colors.white)
- term.write(("[%s] %s: %s"):format(os.date("%H:%M", m.ts or os.time()), m.from, m.text))
- y = y + 1
- end
- term.setCursorPos(2, y+1)
- term.setTextColor(colors.gray)
- term.write("Send DM (phone s-xxxx-xxxx) or type 'back':")
- term.setTextColor(colors.white)
- term.setCursorPos(2, y+2)
- term.write("To: ")
- local to = read()
- if to == "back" then return end
- term.setCursorPos(2, y+3) term.clearLine() term.write("Message: ")
- local txt = read()
- if txt and #txt > 0 then
- local ok, err = sendDM(to, txt)
- term.setCursorPos(2, y+4)
- if ok then term.write("Sent!") else term.write("Failed: " .. tostring(err)) end
- sleep(1)
- end
- end
- end
- -- ===== MAIN LOOP (handles UI and queued message events) =====
- local function mainLoop()
- local selected = 1
- local refreshRate = 1.0
- local timer = os.startTimer(refreshRate)
- while true do
- -- draw screen
- term.setBackgroundColor(config.bgColor)
- term.clear()
- drawTopBar()
- centerText(3, (config.username or "User") .. "'s Lemonphone", config.accentColor)
- local apps = drawMenu(selected)
- local event, a, b = os.pullEvent()
- if event == "timer" then
- if a == timer then
- timer = os.startTimer(refreshRate)
- -- refresh just top bar to update clock/unread
- drawTopBar()
- -- expire banner if needed
- if unreadIndicator and os.time() >= unreadIndicator.expires then unreadIndicator = nil unreadCount = math.max(0, unreadCount - 1) end
- end
- elseif event == "key" then
- local key = a
- if key == keys.up then selected = math.max(1, selected - 1)
- elseif key == keys.down then selected = math.min(7, selected + 1)
- elseif key == keys.enter then
- local appsList = apps
- local chosen = appsList[selected]
- if chosen.name == "Settings" then openSettings()
- elseif chosen.name == "About Lemonphone" then aboutPage()
- elseif chosen.name == "Messages" then
- -- if not registered, prompt to register
- if not myPhoneNumber then
- term.setCursorPos(2, 12) term.clearLine() term.write("You are not registered. Register now? (y/n): ")
- local c = read()
- if c == "y" then registerWithAnyServer("2") end
- end
- messagesUI()
- else
- -- run external apps by name (if present) else ignore
- term.setBackgroundColor(config.bgColor) term.clear()
- term.setCursorPos(1,1)
- -- we run shell.run on a filename: attempt to shell.run(chosen.cmd)
- pcall(shell.run, chosen.cmd)
- end
- end
- elseif event == "lemonos_new_message" then
- local fromPhone, text = a, b
- -- bump unread & banner, beep
- unreadCount = unreadCount + 1
- unreadIndicator = { from = fromPhone, text = text, expires = os.time() + 3 }
- beep()
- -- redraw top bar to show badge immediately
- drawTopBar()
- end
- end
- end
- -- ===== START BACKGROUND TASKS AND UI =====
- -- ensure rednet open
- if peripheral.find("modem") then rednet.open(config.rednetSide) end
- -- try to set myPhoneNumber from config
- myPhoneNumber = config.phoneNumber
- -- run everything in parallel: updateSignal, chatListener, mainLoop
- parallel.waitForAny(updateSignal, chatListener, mainLoop)
Advertisement
Add Comment
Please, Sign In to add comment