Загрузка данных
-- MineLinux 0.19 responsive Paint and interpolated mouse strokes for OpenComputers / OpenOS
-- Added Notepad, Paint, Calculator, Calendar and host clipboard paste support.
-- Added first-run setup, persistent local accounts, account chooser, and per-user startup password checks.
-- Includes off-screen GPU buffering, a working minimized-window taskbar, and maximize/restore controls.
-- Target: Minecraft 1.7.10, OpenComputers 1.12.x (GTNH)
-- Includes left-side settings categories, EN/RU UI, real-world clock, and storage/RAM usage bars.
-- This is a userland desktop environment; OpenOS remains the base system.
local component = require("component")
local computer = require("computer")
local filesystem = require("filesystem")
local event = require("event")
local term = require("term")
local unicode = nil
pcall(function() unicode = require("unicode") end)
local VERSION = "0.19"
local gpu = component.isAvailable("gpu") and component.gpu or nil
local screen = component.isAvailable("screen") and component.screen or nil
local cwd = "/home"
local currentApp = nil
local refreshEntries
local running = true
local leavingToOpenOS = false
local menuOpen = false
local shutdownConfirm = false
local themeIndex = 1
local history = {}
local maxHistory = 250
local appWindows = {}
local minimizedApps = {}
local nextWindowId = 1
-- Off-screen GPU buffer: draw a complete frame here, then copy it to the screen in one GPU operation.
local backBuffer, backBufferWidth, backBufferHeight = nil, nil, nil
local bufferAttempted = false
local fileClipboard = nil
local lastFileClickPath, lastFileClickTime = nil, 0
local settingsPath = "/home/.minelinux.cfg"
local accountsPath = "/home/.minelinux-users"
local DESKTOP_PATH = "/home/MineLinux/Desktop"
local desktopState = {
layoutPath = "/home/MineLinux/.desktop-layout", layout = {}, layoutLoaded = false,
contextMenu = nil, selectedEntry = nil, notice = nil,
lastClickEntry = nil, lastClickTime = 0, programEntriesCache = nil
}
-- Cache desktop program discovery. Reading every .mlnk file during every redraw
-- causes needless HDD activity (especially while typing in a window). The cache
-- is invalidated when known filesystem-changing operations touch Desktop.
local function invalidateDesktopPrograms()
desktopState.programEntriesCache = nil
end
local function pathTouchesDesktop(path)
if not path then return false end
local ok, canonical = pcall(filesystem.canonical, tostring(path))
if not ok or type(canonical) ~= "string" then return false end
return canonical == DESKTOP_PATH or canonical:sub(1, #DESKTOP_PATH + 1) == DESKTOP_PATH .. "/"
end
local function invalidateDesktopProgramsIfPath(path)
if pathTouchesDesktop(path) then invalidateDesktopPrograms() end
end
local VMFS_ROOT = "/home/MineLinux/VirtualFS"
local BIOS_LABEL = "minelinux_BIOS"
local BIOS_PROFILE_PATH = "/home/MineLinux/minelinux_BIOS"
local biosStatus = "not checked"
local biosWarning = nil
local biosLabelStatus = "EEPROM unavailable"
local legacySettingsExisted = filesystem.exists(settingsPath)
local users = {}
local activeUserIndex = 1
local selectedLoginIndex = 1
local firstRun = false
local authMode = "login"
local setupState = nil
local prefs = {
username = "User", avatar = "@", passwordHash = "", lockAtStartup = false,
theme = 1, wallpaper = 1, clock24 = true, language = "en",
realLifeClock = true, showDate = true
}
-- Desktop translations. Terminal commands remain conventional Linux commands.
local strings = {
en = {
computer="Computer", home="Home", terminal="Terminal", trash="Trash", menu="Menu",
applications="Applications", notepad="Notepad", paint="Paint", calculator="Calculator", calendar="Calendar", app_center="Application Center", file_manager="File Manager", system_monitor="System Monitor", virtual_machine="Test VM", desktop_shortcut="Add desktop shortcut", vm_title="Lua Test VM", vmfs_title="Virtual filesystem", vm_instructions="Run: testvm /path/to/program.lua", vm_isolated="Programs only see the virtual filesystem (vfs / io).", vm_persistent="Virtual files persist between tests.", vm_not_found="Program not found", vm_complete="Sandbox run finished", bios_profile="BIOS profile", bios_verified="Profile verified", bios_created="Profile initialized", bios_warning="Profile signature mismatch", bios_label="EEPROM label",
app_hint="Choose an app to launch", new="New", open="Open", save="Save", save_as="Save As", clear_canvas="Clear", load="Load", save_path="Save path", open_path="Open path", modified="Modified", saved="Saved", no_file="File not found", paint_hint="Hold left mouse and drag to draw smoothly", paint_saved="Drawing saved", paint_loaded="Drawing loaded", paint_empty="No saved drawing", result="Result", invalid_expression="Invalid expression",
settings="Settings", user_account="User Account", about="About MineLinux", return_openos="Return to OpenOS", shutdown="Shut Down", shutdown_confirm="Shut down this computer?", shutdown_yes="Shut down", cancel="Cancel",
settings_title="System Settings", appearance="Appearance", clock="Clock & Time", language="Language",
account="Account", system="System", theme="Theme", wallpaper="Wallpaper", reset_windows="Reset window positions",
manage_users="Manage accounts", users_title="User Accounts", add_user="Add user", delete_user="Delete user", current_user="Current", switch_user="Switch user", create_account="Create account", select_account="Select account", wrong_password="Incorrect password.", setup_title="Initial setup", setup_subtitle="Create your first MineLinux account", setup_name="Username", setup_avatar="Avatar", setup_password="Password (optional)", setup_confirm="Confirm password", setup_language="Language", setup_theme="Theme", setup_instructions="Enter: next/save F2: language F3: theme", setup_done="Setup complete", user_count="accounts", user_no_password="no password", user_has_password="password required", delete_confirm="Press Y to confirm deletion, N to cancel.", cannot_delete_active="Switch to another account before deleting this one.", last_account="The last account cannot be deleted.", duplicate_user="That username already exists.",
real_life_clock="Real-life clock", clock_format="Time format", show_date="Show date", date="Date",
hour24="24-hour", hour12="12-hour", enabled="ON", disabled="OFF", switched_language="Language changed.",
now="Current time", system_clock="Uses the computer's real-world clock.", session_clock="Shows MineLinux session time.",
interface_language="Interface language", switch_language="Click to switch English / Russian", english="English", russian="Russian",
user_profile="User profile", profile_name="Name", avatar="Avatar", account_settings="Open account settings",
startup_lock="Password at startup", reset_layout="Reset all window positions", config_file="Settings file",
theme_hint="Click to cycle themes", wallpaper_hint="Click to cycle wallpapers", settings_saved="Settings are saved automatically.",
saved_in="Saved in", selected="Selected", clock_source="Clock source", live_preview="Live preview",
distribution="Distribution", desktop="Desktop", base_system="Base system", minecraft="Minecraft",
memory="Memory", storage="Disk", ram="RAM", storage_unavailable="Storage info unavailable",
used_percent="used", space_used="Used", space_total="Total", memory_used="Memory used", memory_free="Memory free", uptime="Uptime", components="Components", screen="Screen",
empty_folder="This folder is empty", file_actions="File actions", up="Up", places="PLACES",
welcome="Welcome", login="MineLinux Login", password="Password", unlock="Press Enter to unlock",
return_note="OpenOS remains the underlying system.", account_profile="Account profile", save="SAVE", clear="CLEAR",
click_field="Click a field, type, then save.", enter_next="Enter moves to next field; Esc returns.",
text_viewer="Text Viewer", empty_file="(empty file)", close_return="Esc / close to return to Files", about_line="A Linux-inspired desktop for OpenComputers.",
about_gtnh="Designed for Minecraft 1.7.10 / GTNH.", trash_empty="Trash is empty", trash_note="Deleted files are not automatically moved here.",
root="Root /", desktop_place="Desktop", documents="Documents", downloads="Downloads", pictures="Pictures",
confirm="Confirm", type_avatar="type 1-2 chars", saved_password="Saved password", yes="yes", no="no",
up_folder="Parent folder", empty_file="(empty file)", create="Create", delete="Delete", rename="Rename", copy="Copy", cut="Cut", paste="Paste"
},
ru = {
computer="Компьютер", home="Домой", terminal="Терминал", trash="Корзина", menu="Пуск",
applications="Приложения", notepad="Блокнот", paint="Paint", calculator="Калькулятор", calendar="Календарь", app_center="Центр приложений", file_manager="Проводник", system_monitor="Монитор системы", virtual_machine="Тестовая ВМ", desktop_shortcut="Ярлык на рабочий стол", vm_title="Lua Test VM", vmfs_title="Виртуальная ФС", vm_instructions="Запуск: testvm /путь/программа.lua", vm_isolated="Программы видят только виртуальную ФС (vfs / io).", vm_persistent="Файлы ВМ сохраняются между тестами.", vm_not_found="Программа не найдена", vm_complete="Тест в песочнице завершён", bios_profile="Профиль BIOS", bios_verified="Профиль проверен", bios_created="Профиль создан", bios_warning="Подпись профиля не совпала", bios_label="Метка EEPROM",
app_hint="Выбери программу", new="Создать", open="Открыть", save="Сохранить", save_as="Сохранить как", clear_canvas="Очистить", load="Загрузить", save_path="Путь сохранения", open_path="Путь файла", modified="Изменён", saved="Сохранено", no_file="Файл не найден", paint_hint="Зажми левую кнопку и веди мышью для плавного рисования", paint_saved="Рисунок сохранён", paint_loaded="Рисунок загружен", paint_empty="Нет сохранённого рисунка", result="Результат", invalid_expression="Неверное выражение",
settings="Настройки", user_account="Учётная запись", about="О MineLinux", return_openos="Вернуться в OpenOS", shutdown="Завершение работы", shutdown_confirm="Выключить компьютер?", shutdown_yes="Выключить", cancel="Отмена",
settings_title="Настройки системы", appearance="Внешний вид", clock="Часы и время", language="Язык",
account="Профиль", system="Система", theme="Тема оформления", wallpaper="Обои", reset_windows="Сбросить окна",
manage_users="Управление учётками", users_title="Учётные записи", add_user="Добавить", delete_user="Удалить", current_user="Текущая", switch_user="Сменить пользователя", create_account="Создать учётку", select_account="Выбрать пользователя", wrong_password="Неверный пароль.", setup_title="Первоначальная настройка", setup_subtitle="Создай первую учётную запись MineLinux", setup_name="Имя пользователя", setup_avatar="Аватар", setup_password="Пароль (необязательно)", setup_confirm="Повтори пароль", setup_language="Язык", setup_theme="Тема", setup_instructions="Enter: далее/сохранить F2: язык F3: тема", setup_done="Настройка завершена", user_count="учёток", user_no_password="без пароля", user_has_password="нужен пароль", delete_confirm="Y — подтвердить удаление, N — отменить.", cannot_delete_active="Перед удалением войди в другую учётку.", last_account="Нельзя удалить последнюю учётку.", duplicate_user="Такое имя уже существует.",
real_life_clock="Реальное время", clock_format="Формат времени", show_date="Показывать дату", date="Дата",
hour24="24 часа", hour12="12 часов", enabled="ВКЛ", disabled="ВЫКЛ", switched_language="Язык изменён.",
now="Сейчас", system_clock="Используется реальное время компьютера.", session_clock="Показывается время сессии MineLinux.",
interface_language="Язык интерфейса", switch_language="Нажми, чтобы сменить EN / RU", english="Английский", russian="Русский",
user_profile="Профиль пользователя", profile_name="Имя", avatar="Аватар", account_settings="Настройки учётной записи",
startup_lock="Пароль при запуске", reset_layout="Сбросить позиции всех окон", config_file="Файл настроек",
theme_hint="Нажми для смены темы", wallpaper_hint="Нажми для смены обоев", settings_saved="Настройки сохраняются автоматически.",
saved_in="Сохранено в", selected="Выбрано", clock_source="Источник времени", live_preview="Текущее время",
distribution="Система", desktop="Рабочий стол", base_system="Основа", minecraft="Minecraft",
memory="Память", storage="Диск", ram="ОЗУ", storage_unavailable="Нет данных о диске",
used_percent="занято", space_used="Занято", space_total="Всего", memory_used="Занято памяти", memory_free="Свободно памяти", uptime="Время работы", components="Компоненты", screen="Экран",
empty_folder="Папка пуста", file_actions="Действия с файлами", up="Вверх", places="МЕСТА",
welcome="Добро пожаловать", login="Вход в MineLinux", password="Пароль", unlock="Enter — войти",
return_note="OpenOS остаётся базовой системой.", account_profile="Профиль", save="СОХРАНИТЬ", clear="ОЧИСТИТЬ",
click_field="Выбери поле, введи текст и сохрани.", enter_next="Enter — следующее поле; Esc — назад.",
text_viewer="Просмотр текста", empty_file="(пустой файл)", close_return="Esc / закрытие — назад к файлам", about_line="Рабочий стол в стиле Linux для OpenComputers.",
about_gtnh="Для Minecraft 1.7.10 / GTNH.", trash_empty="Корзина пуста", trash_note="Удалённые файлы сюда автоматически не переносятся.",
root="Корень /", desktop_place="Рабочий стол", documents="Документы", downloads="Загрузки", pictures="Изображения",
confirm="Подтвердить", type_avatar="1–2 символа", saved_password="Пароль сохранён", yes="да", no="нет",
up_folder="Родительская папка", create="Создать", delete="Удалить", rename="Переименовать", copy="Копировать", cut="Вырезать", paste="Вставить"
}
}
local function tr(key)
local lang = prefs.language == "ru" and "ru" or "en"
return (strings[lang] and strings[lang][key]) or strings.en[key] or key
end
-- Ignore the OpenOS soft interrupt (Ctrl+C) and hard interrupt (Ctrl+Alt+C)
-- while MineLinux is active. The native OpenOS shell can still be reached with
-- the explicit `openos` command; this is not a firmware-level or tamper-proof lock.
local interruptGuard = {installed = false}
local function installInterruptGuard()
local ok, processLib = pcall(require, "process")
if not ok or type(processLib) ~= "table" or type(processLib.info) ~= "function" then
return false
end
local infoOk, info = pcall(processLib.info)
if not infoOk or type(info) ~= "table" or type(info.data) ~= "table" then
return false
end
local oldHandler = info.data.signal
if type(oldHandler) ~= "function" then return false end
local guardedHandler = function(signalName, ...)
if signalName == "interrupted" then return end
return oldHandler(signalName, ...)
end
local setOk = pcall(function() info.data.signal = guardedHandler end)
if not setOk then return false end
interruptGuard.processLib = processLib
interruptGuard.data = info.data
interruptGuard.oldHandler = oldHandler
interruptGuard.guardedHandler = guardedHandler
interruptGuard.installed = true
return true
end
local function restoreInterruptGuard()
if not interruptGuard.installed then return end
pcall(function()
if interruptGuard.data and interruptGuard.data.signal == interruptGuard.guardedHandler then
interruptGuard.data.signal = interruptGuard.oldHandler
end
end)
interruptGuard.installed = false
end
local function ulen(value)
value = tostring(value or "")
if unicode and unicode.len then
local ok, n = pcall(unicode.len, value)
if ok and type(n) == "number" then return n end
end
return #value
end
local function usub(value, first, last)
value = tostring(value or "")
if unicode and unicode.sub then
local ok, result = pcall(unicode.sub, value, first, last)
if ok and type(result) == "string" then return result end
end
return value:sub(first, last)
end
local function charToString(codepoint)
codepoint = tonumber(codepoint)
if not codepoint or codepoint < 0 then return "" end
if unicode and unicode.char then
local ok, result = pcall(unicode.char, codepoint)
if ok and type(result) == "string" then return result end
end
if codepoint <= 255 then return string.char(codepoint) end
return ""
end
local function sanitizeSetting(value, limit)
value = tostring(value or "")
value = value:gsub("[\r\n\t]", " "):gsub("=", "-"):gsub("|", "-")
return value:sub(1, limit or 64)
end
local function loadPrefs()
local f = io.open(settingsPath, "r")
if not f then return end
for line in f:lines() do
local key, value = line:match("^([%w_]+)=(.*)$")
if key and value then
if key == "username" then prefs.username = sanitizeSetting(value, 24)
elseif key == "avatar" then prefs.avatar = sanitizeSetting(value, 2)
elseif key == "passwordHash" then prefs.passwordHash = value:sub(1, 32)
elseif key == "lockAtStartup" then prefs.lockAtStartup = value == "true"
elseif key == "theme" then prefs.theme = tonumber(value) or 1
elseif key == "wallpaper" then prefs.wallpaper = tonumber(value) or 1
elseif key == "clock24" then prefs.clock24 = value ~= "false"
elseif key == "language" then prefs.language = value == "ru" and "ru" or "en"
elseif key == "realLifeClock" then prefs.realLifeClock = value ~= "false"
elseif key == "showDate" then prefs.showDate = value ~= "false" end
end
end
f:close()
end
local function savePrefs()
pcall(function() filesystem.makeDirectory("/home") end)
local f, err = io.open(settingsPath, "w")
if not f then return false, err end
f:write("username=" .. sanitizeSetting(prefs.username, 24) .. "\n")
f:write("avatar=" .. sanitizeSetting(prefs.avatar, 2) .. "\n")
f:write("passwordHash=" .. tostring(prefs.passwordHash or "") .. "\n")
f:write("lockAtStartup=" .. tostring(prefs.lockAtStartup == true) .. "\n")
f:write("theme=" .. tostring(prefs.theme or 1) .. "\n")
f:write("wallpaper=" .. tostring(prefs.wallpaper or 1) .. "\n")
f:write("clock24=" .. tostring(prefs.clock24 ~= false) .. "\n")
f:write("language=" .. ((prefs.language == "ru") and "ru" or "en") .. "\n")
f:write("realLifeClock=" .. tostring(prefs.realLifeClock ~= false) .. "\n")
f:write("showDate=" .. tostring(prefs.showDate ~= false) .. "\n")
f:close()
return true
end
-- This local profile hash is an obfuscation mechanism for a game computer,
-- not a cryptographically secure password store.
local function hashPassword(value)
local h1, h2 = 5381, 7919
value = "MineLinux-local:" .. tostring(value or "")
for i = 1, #value do
local b = value:byte(i)
h1 = (h1 * 33 + b) % 2147483647
h2 = (h2 * 131 + b) % 2147483629
end
return string.format("%08x%08x", h1, h2)
end
local function saveUsers()
pcall(function() filesystem.makeDirectory("/home") end)
local tempPath = accountsPath .. ".tmp"
local f, err = io.open(tempPath, "w")
if not f then return false, err end
for _, user in ipairs(users) do
local name = sanitizeSetting(user.username or "User", 24)
local avatar = sanitizeSetting(user.avatar or "@", 2)
local hash = tostring(user.passwordHash or ""):gsub("[^%x]", ""):sub(1, 32)
f:write(name .. "|" .. avatar .. "|" .. hash .. "\n")
end
f:close()
local renameCallOk = pcall(filesystem.rename, tempPath, accountsPath)
if renameCallOk and not filesystem.exists(tempPath) and filesystem.exists(accountsPath) then return true end
-- Fallback for filesystems whose rename cannot overwrite an existing file.
local src = io.open(tempPath, "r")
local data = src and src:read("*a") or nil
if src then src:close() end
if not data then return false, "could not read temporary account file" end
local dst, dstErr = io.open(accountsPath, "w")
if not dst then return false, dstErr end
dst:write(data); dst:close()
pcall(filesystem.remove, tempPath)
return true
end
local function loadUsers()
users = {}
local f = io.open(accountsPath, "r")
if not f then return false end
for line in f:lines() do
local name, avatar, hash = line:match("^([^|]*)|([^|]*)|([%x]*)$")
if name and name ~= "" then
users[#users + 1] = {
username = sanitizeSetting(name, 24),
avatar = sanitizeSetting(avatar ~= "" and avatar or "@", 2),
passwordHash = hash or ""
}
end
end
f:close()
return #users > 0
end
local function accountNameExists(name, exceptIndex)
local key = tostring(name or ""):lower()
for i, user in ipairs(users) do
if i ~= exceptIndex and tostring(user.username or ""):lower() == key then return true end
end
return false
end
local function saveActiveAccount()
local user = users[activeUserIndex]
if not user then return false, "no active account" end
user.username = sanitizeSetting(prefs.username, 24)
user.avatar = sanitizeSetting(prefs.avatar, 2)
user.passwordHash = tostring(prefs.passwordHash or "")
return saveUsers()
end
loadPrefs()
local usersLoaded = loadUsers()
if not usersLoaded then
if legacySettingsExisted then
-- Migrate existing single-user profiles without losing the old username/password.
users[1] = {username = sanitizeSetting(prefs.username or "User", 24), avatar = sanitizeSetting(prefs.avatar or "@", 2), passwordHash = prefs.passwordHash or ""}
activeUserIndex, selectedLoginIndex = 1, 1
saveUsers()
else
firstRun = true
authMode = "setup"
setupState = {username = "", avatar = "@", pass1 = "", pass2 = "", field = 1, language = prefs.language or "en", theme = tonumber(prefs.theme) or 1}
end
end
if #users > 0 then
activeUserIndex = math.max(1, math.min(#users, activeUserIndex))
selectedLoginIndex = activeUserIndex
prefs.username = users[activeUserIndex].username
prefs.avatar = users[activeUserIndex].avatar
prefs.passwordHash = users[activeUserIndex].passwordHash
end
local sessionStart = computer.uptime()
local function clockTime()
if prefs.realLifeClock ~= false then
return os.date(prefs.clock24 and "%H:%M" or "%I:%M %p")
end
local elapsed = math.max(0, math.floor(computer.uptime() - sessionStart))
return string.format("%02d:%02d", math.floor(elapsed / 3600) % 100, math.floor(elapsed / 60) % 60)
end
local function clockDisplay()
local value = clockTime()
if prefs.showDate and prefs.realLifeClock ~= false then
value = os.date("%d.%m") .. " " .. value
end
return value
end
local function clockRefreshKey()
if prefs.realLifeClock ~= false then
return os.date("%Y%m%d%H%M") .. ":" .. tostring(prefs.clock24) .. ":" .. tostring(prefs.showDate)
end
return tostring(math.floor(math.max(0, computer.uptime() - sessionStart) / 60)) .. ":session"
end
-- Persistent personal folders. Existing files and settings are never cleared.
local userDirs = {
"/home/MineLinux", "/home/MineLinux/Desktop", "/home/MineLinux/Documents",
"/home/MineLinux/Downloads", "/home/MineLinux/Pictures", "/home/MineLinux/VirtualFS", "/home/MineLinux/Music",
"/home/MineLinux/Videos", "/home/MineLinux/Trash"
}
for _, dir in ipairs(userDirs) do pcall(filesystem.makeDirectory, dir) end
if filesystem.isDirectory("/home/MineLinux") then cwd = "/home/MineLinux" end
local themes = {
{
name = "Mint Green",
wallpaper = 0x183D35, wallpaper2 = 0x21483D,
panel = 0x171D1B, panelAlt = 0x222A26,
window = 0x202622, title = 0x2F5144,
text = 0xE7F1EA, muted = 0xA7B9AE,
green = 0x78C99A, accent = 0x9BE2B4,
yellow = 0xE7C875, red = 0xE87979,
blue = 0x84B9D8, black = 0x101512
},
{
name = "Dark Teal",
wallpaper = 0x102B31, wallpaper2 = 0x16383D,
panel = 0x121B20, panelAlt = 0x202D32,
window = 0x18252A, title = 0x28505A,
text = 0xE5F3F4, muted = 0x9BB7B8,
green = 0x6ED8B0, accent = 0x76E0D8,
yellow = 0xF0CC78, red = 0xF08080,
blue = 0x80B7EB, black = 0x0A171B
},
{
name = "Nordic Blue",
wallpaper = 0x19283E, wallpaper2 = 0x243953,
panel = 0x101A2A, panelAlt = 0x26364D,
window = 0x1D2A40, title = 0x304A6A,
text = 0xECF2FF, muted = 0xA9B9D3,
green = 0x86D7B2, accent = 0x85C7FF,
yellow = 0xF0D58A, red = 0xF18B98,
blue = 0x81B9F5, black = 0x0C1421
},
{
name = "Ubuntu Purple",
wallpaper = 0x2B183C, wallpaper2 = 0x402554,
panel = 0x19111F, panelAlt = 0x352440,
window = 0x281B31, title = 0x54366C,
text = 0xF6ECFA, muted = 0xC3AFCB,
green = 0xA6D98D, accent = 0xD7A4F1,
yellow = 0xF2D27D, red = 0xF08C9A,
blue = 0x9ABEFF, black = 0x120B18
},
{
name = "Rose Pine",
wallpaper = 0x282338, wallpaper2 = 0x3B304B,
panel = 0x191724, panelAlt = 0x352E44,
window = 0x26233A, title = 0x52415E,
text = 0xEDE9F5, muted = 0xB9ADC9,
green = 0x9CCFD8, accent = 0xF6C177,
yellow = 0xF6C177, red = 0xEB6F92,
blue = 0xC4A7E7, black = 0x15131F
},
{
name = "Classic Light",
wallpaper = 0xC8D5D0, wallpaper2 = 0xB5C8C0,
panel = 0x26332D, panelAlt = 0xE7EFEA,
window = 0xF0F4F1, title = 0xC4DDD0,
text = 0x1B2A22, muted = 0x4F6257,
green = 0x247748, accent = 0x2C8F57,
yellow = 0x956B08, red = 0xA83232,
blue = 0x225E91, black = 0x17241D
},
{
name = "Amber Night",
wallpaper = 0x302316, wallpaper2 = 0x4B321B,
panel = 0x1A140E, panelAlt = 0x3A2A1B,
window = 0x2A2015, title = 0x594022,
text = 0xF8EBDD, muted = 0xC6AE91,
green = 0xB7D98C, accent = 0xF4C16B,
yellow = 0xF4C16B, red = 0xE98773,
blue = 0x8ABDD0, black = 0x150F0A
}
}
local wallpaperNames = {"Mint Geometry", "Soft Bands", "Night Stars", "Diagonal", "Purple Horizon"}
themeIndex = math.max(1, math.min(#themes, tonumber(prefs.theme) or 1))
prefs.wallpaper = math.max(1, math.min(#wallpaperNames, tonumber(prefs.wallpaper) or 1))
local C = themes[themeIndex]
local function setTheme()
C = themes[themeIndex]
end
local function resolution()
if gpu then
local ok, w, h = pcall(gpu.getResolution)
if ok and w and h then return w, h end
end
return 50, 16
end
-- OpenComputers 1.7.6+ supports GPU off-screen buffers. Rendering the desktop
-- directly to the visible buffer exposes every fill/set operation during a
-- drag. The off-screen buffer keeps intermediate drawing invisible until the
-- completed frame is copied to screen with bitblt.
local function configureBackBuffer(force)
if not gpu then return false end
-- Read the physical screen size from buffer 0, not from a possibly stale
-- off-screen buffer. This is also used after a screen-resized signal.
pcall(gpu.setActiveBuffer, 0)
local sw, sh = resolution()
if backBuffer and backBufferWidth == sw and backBufferHeight == sh then
local switched = pcall(gpu.setActiveBuffer, backBuffer)
local readOk, active = pcall(gpu.getActiveBuffer)
if switched and readOk and active == backBuffer then return true end
end
if backBuffer then
pcall(gpu.setActiveBuffer, 0)
pcall(gpu.freeBuffer, backBuffer)
backBuffer = nil
backBufferWidth, backBufferHeight = nil, nil
end
if bufferAttempted and not force then return false end
bufferAttempted = true
local allocOk, id = pcall(gpu.allocateBuffer, sw, sh)
if not allocOk or type(id) ~= "number" or id == 0 then
pcall(gpu.setActiveBuffer, 0)
return false
end
local switchOk = pcall(gpu.setActiveBuffer, id)
local readOk, active = pcall(gpu.getActiveBuffer)
if switchOk and readOk and active == id then
backBuffer = id
backBufferWidth, backBufferHeight = sw, sh
return true
end
pcall(gpu.setActiveBuffer, 0)
pcall(gpu.freeBuffer, id)
return false
end
local function setColors(fg, bg)
if not gpu then return end
pcall(function()
gpu.setForeground(fg or C.text)
gpu.setBackground(bg or C.wallpaper)
end)
end
local function fillRect(x, y, w, h, color)
if not gpu then return end
local sw, sh = resolution()
x, y = math.floor(x), math.floor(y)
w, h = math.floor(w), math.floor(h)
if w <= 0 or h <= 0 or x > sw or y > sh or x + w - 1 < 1 or y + h - 1 < 1 then return end
if x < 1 then w, x = w - (1 - x), 1 end
if y < 1 then h, y = h - (1 - y), 1 end
w = math.min(w, sw - x + 1)
h = math.min(h, sh - y + 1)
if w <= 0 or h <= 0 then return end
pcall(function()
gpu.setBackground(color or C.wallpaper)
gpu.fill(x, y, w, h, " ")
end)
end
local function text(x, y, value, fg, bg)
if not gpu then return end
local sw, sh = resolution()
x, y = math.floor(x), math.floor(y)
if y < 1 or y > sh or x > sw then return end
value = tostring(value or "")
if x < 1 then value = usub(value, 2 - x); x = 1 end
value = usub(value, 1, math.max(0, sw - x + 1))
if ulen(value) == 0 then return end
pcall(function()
gpu.setForeground(fg or C.text)
gpu.setBackground(bg or C.wallpaper)
gpu.set(x, y, value)
end)
end
local function centerText(y, value, fg, bg)
local w = resolution()
text(math.floor((w - ulen(tostring(value))) / 2) + 1, y, value, fg, bg)
end
local function trim(s)
return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", ""))
end
local function shorten(s, width)
s = tostring(s or "")
if width <= 0 then return "" end
if ulen(s) > width then
if width > 3 then return usub(s, 1, width - 3) .. "..." end
return usub(s, 1, width)
end
return s
end
local function parseArgs(line)
local args, buf, quote = {}, {}, nil
for i = 1, #line do
local ch = line:sub(i, i)
if quote then
if ch == quote then quote = nil else buf[#buf + 1] = ch end
elseif ch == "'" or ch == '"' then
quote = ch
elseif ch:match("%s") then
if #buf > 0 then args[#args + 1] = table.concat(buf); buf = {} end
else
buf[#buf + 1] = ch
end
end
if #buf > 0 then args[#args + 1] = table.concat(buf) end
return args
end
local function resolve(path)
path = path or ""
if path == "" then return cwd end
if path:sub(1, 1) == "/" then return filesystem.canonical(path) end
return filesystem.canonical((cwd == "/" and "" or cwd) .. "/" .. path)
end
local function parentPath(path)
path = filesystem.canonical(path)
if path == "/" then return "/" end
local p = path:match("^(.*)/[^/]+$")
if not p or p == "" then return "/" end
return p
end
local function pushLog(line, color)
if not currentApp or currentApp.name ~= "terminal" then return end
line = tostring(line or "")
if line == "" then
currentApp.log[#currentApp.log + 1] = {text = "", color = color or C.text}
else
for piece in (line .. "\n"):gmatch("(.-)\n") do
currentApp.log[#currentApp.log + 1] = {text = piece, color = color or C.text}
end
end
while #currentApp.log > maxHistory do table.remove(currentApp.log, 1) end
end
local function rememberedWindow(name)
local v = appWindows[name]
if not v then return nil end
return {x = v.x, y = v.y, w = v.w, h = v.h, maximized = v.maximized}
end
local function newTerminal()
currentApp = {
name = "terminal", title = tr("terminal"), cwd = cwd,
input = "", log = {
{text = "MineLinux Terminal " .. VERSION, color = C.green},
{text = tr("welcome") .. ", " .. prefs.username .. ". Type 'help' for commands.", color = C.muted},
{text = "OpenOS is the base system; MineLinux is the desktop environment.", color = C.muted},
{text = "Use 'os <command>' for OpenOS commands, or 'openos' for the native shell.", color = C.muted},
{text = "Use 'testvm program.lua' to test scripts in an isolated virtual filesystem.",
{text = "Terminal: Up/Down = command history; Tab = command/path completion.", color = C.muted}, color = C.muted},
{text = "", color = C.text}
}, scroll = 0, writeMode = nil, pending = nil, win = rememberedWindow("terminal"),
historyIndex = #history + 1, draftInput = ""
}
end
local function readTextSafe(path)
local f = io.open(path, "r")
if not f then return nil end
local data = f:read("*a") or ""
f:close()
return data
end
local function writeTextSafe(path, data)
path = filesystem.canonical(path or "")
if path == "" or path == "/" or filesystem.isDirectory(path) then return false, "invalid file path" end
local parent = parentPath(path)
if not filesystem.exists(parent) then
local made, why = filesystem.makeDirectory(parent)
if not made and not filesystem.isDirectory(parent) then return false, why or "could not create parent directory" end
end
local f, err = io.open(path, "w")
if not f then return false, err end
f:write(tostring(data or ""))
f:close()
return true
end
-- A desktop shortcut is a tiny text file pointing at an existing .lua program.
-- The source program itself is never moved or rewritten.
local function createDesktopShortcut(sourcePath)
sourcePath = filesystem.canonical(tostring(sourcePath or ""))
if not filesystem.exists(sourcePath) or filesystem.isDirectory(sourcePath) then
return false, "Select an existing Lua file first."
end
if not sourcePath:lower():match("%.lua$") then return false, "Only .lua programs can be pinned." end
local okDir, dirErr = pcall(filesystem.makeDirectory, DESKTOP_PATH)
if not okDir and not filesystem.isDirectory(DESKTOP_PATH) then return false, tostring(dirErr or "cannot create Desktop") end
local base = sourcePath:match("([^/]+)$") or "program.lua"
local stem = base:gsub("%.lua$", "")
stem = stem:gsub("[^%w_%- ]", "_")
if stem == "" then stem = "Lua program" end
local candidate = DESKTOP_PATH .. "/" .. stem .. ".mlnk"
local suffix = 2
while filesystem.exists(candidate) do
local existing = readTextSafe(candidate) or ""
if existing:match("^([^\r\n]+)") == sourcePath then return true, "Shortcut already exists." end
candidate = DESKTOP_PATH .. "/" .. stem .. " (" .. suffix .. ").mlnk"
suffix = suffix + 1
if suffix > 99 then return false, "Too many shortcuts with this name." end
end
local written, writeErr = writeTextSafe(candidate, sourcePath .. "\n")
if not written then return false, tostring(writeErr) end
invalidateDesktopPrograms()
return true, "Desktop shortcut created: " .. base
end
desktopState.loadLayout = function()
if desktopState.layoutLoaded then return end
desktopState.layoutLoaded = true
desktopState.layout = {}
local raw = readTextSafe(desktopState.layoutPath) or ""
for line in raw:gmatch("[^\r\n]+") do
local name, xs, ys = line:match("^(.-)\t(%d+)\t(%d+)$")
local x, y = tonumber(xs), tonumber(ys)
if name and name ~= "" and x and y then
desktopState.layout[name] = {x = x, y = y}
end
end
end
desktopState.saveLayout = function()
desktopState.loadLayout()
local names, lines = {}, {}
for name in pairs(desktopState.layout) do names[#names + 1] = name end
table.sort(names, function(a, b) return a:lower() < b:lower() end)
for _, name in ipairs(names) do
local pos = desktopState.layout[name]
if pos and tonumber(pos.x) and tonumber(pos.y) then
lines[#lines + 1] = name .. "\t" .. math.floor(pos.x) .. "\t" .. math.floor(pos.y)
end
end
local ok, err = writeTextSafe(desktopState.layoutPath, table.concat(lines, "\n") .. (#lines > 0 and "\n" or ""))
if not ok then desktopState.notice = "Could not save desktop positions: " .. tostring(err); return false, err end
return true
end
local function desktopPrograms()
desktopState.loadLayout()
local entries = desktopState.programEntriesCache
if not entries then
entries = {}
if filesystem.isDirectory(DESKTOP_PATH) then
local iter = filesystem.list(DESKTOP_PATH)
if iter then
for entry in iter do
local name = entry:gsub("/$", "")
local full = filesystem.canonical(DESKTOP_PATH .. "/" .. name)
if not filesystem.isDirectory(full) then
local target, label
if name:lower():match("%.lua$") then
target, label = full, name:gsub("%.lua$", "")
elseif name:lower():match("%.mlnk$") then
local contents = readTextSafe(full) or ""
target = contents:match("^([^\r\n]+)")
label = name:gsub("%.mlnk$", "")
end
if target and filesystem.exists(target) and not filesystem.isDirectory(target) and target:lower():match("%.lua$") then
entries[#entries + 1] = {path = target, label = label or (target:match("([^/]+)$") or "Lua"), entryName = name, entryPath = full}
end
end
end
end
end
table.sort(entries, function(a, b) return a.label:lower() < b.label:lower() end)
desktopState.programEntriesCache = entries
end
-- Geometry is cheap and stays fresh while dragging icons; the disk-backed
-- directory scan above runs only after the cache is invalidated.
local result = {}
local sw, sh = resolution()
local cols = math.max(1, math.floor((sw - 26 + 1) / 12))
for i, cached in ipairs(entries) do
local item = {path = cached.path, label = cached.label, entryName = cached.entryName, entryPath = cached.entryPath}
local row = math.floor((i - 1) / cols)
local col = (i - 1) % cols
local defaultX, defaultY = 26 + col * 12, 7 + row * 4
local saved = desktopState.layout[item.entryName]
item.x = math.max(1, math.min(math.max(1, sw - 10), saved and saved.x or defaultX))
item.y = math.max(2, math.min(math.max(2, sh - 4), saved and saved.y or defaultY))
result[#result + 1] = item
end
return result
end
-- Separate persistent virtual filesystem used by the Lua test sandbox.
-- Virtual paths are always rooted at / and cannot escape into the real filesystem.
local function vmCanonical(path)
path = tostring(path or "/"):gsub("\\", "/")
local stack = {}
for part in path:gmatch("[^/]+") do
if part == ".." then
if #stack > 0 then table.remove(stack) end
elseif part ~= "." and part ~= "" then
stack[#stack + 1] = part
end
end
return "/" .. table.concat(stack, "/")
end
local function vmHostPath(path)
local normalized = vmCanonical(path)
return VMFS_ROOT .. (normalized == "/" and "" or normalized)
end
local function vmRead(path)
local host = vmHostPath(path)
if not filesystem.exists(host) or filesystem.isDirectory(host) then return nil, "no such virtual file" end
local f, err = io.open(host, "r")
if not f then return nil, err end
local data = f:read("*a") or ""
f:close()
return data
end
local function vmWrite(path, data, append)
local host = vmHostPath(path)
if host == VMFS_ROOT or filesystem.isDirectory(host) then return false, "virtual path is a directory" end
local parent = parentPath(host)
local made, makeErr = filesystem.makeDirectory(parent)
if not made and not filesystem.isDirectory(parent) then return false, makeErr or "cannot create virtual folder" end
local f, err = io.open(host, append and "a" or "w")
if not f then return false, err end
f:write(tostring(data or "")); f:close()
return true
end
local function vmList(path)
local host = vmHostPath(path)
if not filesystem.exists(host) or not filesystem.isDirectory(host) then return nil, "no such virtual directory" end
local iter, err = filesystem.list(host)
if not iter then return nil, err end
local entries = {}
for entry in iter do entries[#entries + 1] = entry end
table.sort(entries, function(a,b) return a:lower() < b:lower() end)
return entries
end
local function vmExists(path) return filesystem.exists(vmHostPath(path)) end
local function vmIsDirectory(path) return filesystem.isDirectory(vmHostPath(path)) end
local function vmMkdir(path)
local host = vmHostPath(path)
return filesystem.makeDirectory(host)
end
local function vmRemove(path)
local normalized = vmCanonical(path)
if normalized == "/" then return false, "cannot remove virtual root" end
return filesystem.remove(vmHostPath(normalized))
end
local function vmOpen(path, mode)
mode = tostring(mode or "r")
local normalized, host = vmCanonical(path), vmHostPath(path)
if normalized == "/" then return nil, "cannot open virtual root" end
local wantsWrite = mode:find("w", 1, true) or mode:find("a", 1, true) or mode:find("+", 1, true)
local initial = ""
if filesystem.exists(host) then
if filesystem.isDirectory(host) then return nil, "virtual path is a directory" end
local got, readErr = vmRead(normalized)
if got == nil then return nil, readErr end
initial = got
elseif not wantsWrite then
return nil, "no such virtual file"
end
if mode:find("w", 1, true) then initial = "" end
local obj = {data = initial, pos = mode:find("a", 1, true) and (#initial + 1) or 1, closed = false, writable = wantsWrite, path = normalized}
function obj:read(fmt)
if self.closed then return nil, "file is closed" end
fmt = fmt or "*l"
if fmt == "*a" or fmt == "*all" then
local out = self.data:sub(self.pos); self.pos = #self.data + 1; return out
elseif fmt == "*l" or fmt == "*line" then
if self.pos > #self.data then return nil end
local stop = self.data:find("\n", self.pos, true)
local out
if stop then out = self.data:sub(self.pos, stop - 1); self.pos = stop + 1
else out = self.data:sub(self.pos); self.pos = #self.data + 1 end
out = out:gsub("\r$", "")
return out
elseif type(fmt) == "number" then
if self.pos > #self.data then return nil end
local out = self.data:sub(self.pos, self.pos + fmt - 1); self.pos = self.pos + #out; return out
end
return nil, "unsupported virtual read mode"
end
function obj:write(...)
if self.closed then return nil, "file is closed" end
if not self.writable then return nil, "file is read-only" end
local parts = {}
for i = 1, select("#", ...) do parts[#parts + 1] = tostring(select(i, ...)) end
local value = table.concat(parts)
self.data = self.data:sub(1, self.pos - 1) .. value .. self.data:sub(self.pos)
self.pos = self.pos + #value
return self
end
function obj:flush() return true end
function obj:lines()
return function() return self:read("*l") end
end
function obj:close()
if self.closed then return true end
self.closed = true
if self.writable then return vmWrite(self.path, self.data, false) end
return true
end
return obj
end
local vmfs = {
canonical = vmCanonical, open = vmOpen, read = vmRead, write = vmWrite,
list = vmList, exists = vmExists, isDirectory = vmIsDirectory,
makeDirectory = vmMkdir, remove = vmRemove
}
local function ensureWindowId(target)
if target and not target._windowId then
target._windowId = nextWindowId
nextWindowId = nextWindowId + 1
end
return target and target._windowId or nil
end
local function parkCurrentApp()
if not currentApp then return end
local id = ensureWindowId(currentApp)
for _, item in ipairs(minimizedApps) do
if item.id == id then
item.app = currentApp
currentApp = nil
return
end
end
minimizedApps[#minimizedApps + 1] = {id = id, app = currentApp}
currentApp = nil
end
local function restoreMinimized(id)
local target, foundIndex
for i, item in ipairs(minimizedApps) do
if item.id == id then target, foundIndex = item.app, i; break end
end
if not target then return false end
table.remove(minimizedApps, foundIndex)
if currentApp then parkCurrentApp() end
currentApp = target
menuOpen = false
return true
end
local function minimizedIndexByName(name)
for i, item in ipairs(minimizedApps) do
if item.app and item.app.name == name then return i end
end
return nil
end
local function taskbarCaption(target)
local captions = {
terminal = tr("terminal"), files = tr("file_manager"), fileview = tr("text_viewer"),
notepad = tr("notepad"), paint = "Paint", calculator = tr("calculator"),
calendar = tr("calendar"), launcher = tr("app_center"), system = tr("system_monitor"),
settings = tr("settings"), account = tr("user_account"), users = tr("manage_users"),
about = tr("about"), trash = tr("trash"), sandbox = tr("virtual_machine")
}
return captions[target and target.name or ""] or (target and target.title) or "App"
end
local function app(name)
-- Re-opening an application restores its minimized instance instead of
-- creating a blank duplicate. The currently active app is kept on the panel.
if currentApp and currentApp.name == name then
menuOpen = false
return
end
local savedTarget
local savedIndex = minimizedIndexByName(name)
if savedIndex then savedTarget = minimizedApps[savedIndex].app end
if currentApp then parkCurrentApp() end
if savedTarget then
for i, item in ipairs(minimizedApps) do
if item.app == savedTarget then table.remove(minimizedApps, i); break end
end
currentApp = savedTarget
menuOpen = false
return
end
if name == "terminal" then
newTerminal()
elseif name == "launcher" then
currentApp = {name = "launcher", title = tr("app_center"), win = rememberedWindow("launcher")}
elseif name == "notepad" then
local path = "/home/Documents/untitled.txt"
local contents = readTextSafe(path) or ""
currentApp = {name = "notepad", title = tr("notepad"), path = path, contents = contents, modified = false, pathMode = nil, pathInput = "", notice = nil, win = rememberedWindow("notepad")}
elseif name == "paint" then
currentApp = {name = "paint", title = tr("paint"), canvas = {}, paintColor = 2, canvasWidth = 0, canvasHeight = 0, savePath = "/home/Pictures/painting.mlp", notice = nil, win = rememberedWindow("paint")}
elseif name == "calculator" then
currentApp = {name = "calculator", title = tr("calculator"), input = "", result = "", win = rememberedWindow("calculator")}
elseif name == "calendar" then
currentApp = {name = "calendar", title = tr("calendar"), monthOffset = 0, win = rememberedWindow("calendar")}
elseif name == "files" then
currentApp = {name = "files", title = tr("file_manager"), path = cwd, selected = 1, selectedPath = nil, entries = {}, win = rememberedWindow("files")}
if refreshEntries then refreshEntries() end
elseif name == "system" then
currentApp = {name = "system", title = tr("system_monitor"), win = rememberedWindow("system")}
elseif name == "settings" then
currentApp = {name = "settings", title = tr("settings_title"), settingsTab = "appearance", win = rememberedWindow("settings")}
elseif name == "account" then
currentApp = {name = "account", title = tr("user_account"), win = rememberedWindow("account"),
form = {username = prefs.username, avatar = prefs.avatar, pass1 = "", pass2 = ""}, editField = "username"}
elseif name == "users" then
currentApp = {name = "users", title = tr("users_title"), win = rememberedWindow("users"), usersMode = "list", userSelected = activeUserIndex}
elseif name == "about" then
currentApp = {name = "about", title = tr("about"), win = rememberedWindow("about")}
elseif name == "trash" then
currentApp = {name = "trash", title = tr("trash"), message = "OpenComputers does not provide a universal desktop trash folder. Files are not moved automatically.", win = rememberedWindow("trash")}
elseif name == "sandbox" then
currentApp = {name = "sandbox", title = tr("vm_title"), win = rememberedWindow("sandbox")}
end
if currentApp then ensureWindowId(currentApp) end
menuOpen = false
end
local function addHit(list, x, y, w, h, action, value)
list[#list + 1] = {x = x, y = y, w = w, h = h, action = action, value = value}
end
local function hitTest(list, x, y)
for i = #list, 1, -1 do
local h = list[i]
if x >= h.x and x < h.x + h.w and y >= h.y and y < h.y + h.h then return h end
end
end
local hits = {}
local function resetHits() hits = {} end
local function hit(x, y, w, h, action, value) addHit(hits, x, y, w, h, action, value) end
local function drawIcon(x, y, label, caption, color, action, value)
local w = math.max(8, ulen(caption) + 2)
fillRect(x, y, w, 3, C.wallpaper2)
text(x + 1, y, label, color or C.green, C.wallpaper2)
text(x, y + 1, shorten(caption, w), C.text, C.wallpaper2)
text(x, y + 2, string.rep("-", w), C.muted, C.wallpaper2)
hit(x, y, w, 3, action, value)
end
local function drawDesktopBase()
local w, h = resolution()
fillRect(1, 1, w, h, C.wallpaper)
if prefs.wallpaper == 1 then
fillRect(1, math.max(2, math.floor(h * 0.25)), w, math.max(1, math.floor(h * 0.25)), C.wallpaper2)
fillRect(math.max(1, math.floor(w * 0.55)), 2, math.max(1, w - math.floor(w * 0.55)), math.max(1, h - 3), C.wallpaper)
elseif prefs.wallpaper == 2 then
for yy = 2, h - 3 do
if yy % 4 < 2 then fillRect(1, yy, w, 1, C.wallpaper2) end
end
elseif prefs.wallpaper == 3 then
-- Deterministic star field: never uses random values, so redraws stay stable.
for yy = 2, h - 4 do
for xx = 2, w - 2 do
if ((xx * 17 + yy * 31) % 47) == 0 then text(xx, yy, ".", C.muted, C.wallpaper) end
end
end
fillRect(1, math.max(2, math.floor(h * 0.67)), w, math.max(1, math.floor(h * 0.15)), C.wallpaper2)
elseif prefs.wallpaper == 4 then
for yy = 2, h - 4 do
local xx = ((yy * 3) % math.max(1, w - 8)) + 1
fillRect(xx, yy, math.min(8, w - xx + 1), 1, C.wallpaper2)
end
else
fillRect(1, math.max(2, math.floor(h * 0.4)), w, math.max(1, math.floor(h * 0.2)), C.wallpaper2)
fillRect(1, math.max(2, math.floor(h * 0.6)), math.max(1, math.floor(w * 0.65)), math.max(1, math.floor(h * 0.17)), C.title)
fillRect(math.max(1, math.floor(w * 0.45)), 2, math.max(1, math.floor(w * 0.35)), math.max(1, math.floor(h * 0.18)), C.title)
end
text(math.max(12, math.floor(w / 2) - 5), math.max(5, math.floor(h / 2) - 1), "MineLinux", C.green, C.wallpaper2)
text(math.max(12, math.floor(w / 2) - 8), math.max(6, math.floor(h / 2)), "CINNAMON DESKTOP", C.muted, C.wallpaper2)
drawIcon(2, 3, "[PC]", tr("computer"), C.blue, "app", "system")
drawIcon(2, 7, "[DIR]", tr("home"), C.yellow, "app", "files")
drawIcon(2, 11, "[>_]", tr("terminal"), C.green, "app", "terminal")
if w >= 34 then
drawIcon(14, 3, "[TXT]", tr("notepad"), C.green, "app", "notepad")
drawIcon(14, 7, "[ART]", tr("paint"), C.accent, "app", "paint")
if h >= 16 then drawIcon(14, 11, "[CAL]", tr("calculator"), C.blue, "app", "calculator") end
end
if h >= 19 then drawIcon(26, 3, "[BIN]", tr("trash"), C.red, "app", "trash") end
-- User Lua programs and .mlnk shortcuts appear as desktop icons.
local programs = desktopPrograms()
for _, item in ipairs(programs) do
local ix, iy = item.x, item.y
if ix + 2 < w and iy + 2 < h then
local cellW = math.min(11, w - ix + 1)
local selected = desktopState.selectedEntry == item.entryName
local bg = selected and C.title or C.wallpaper2
fillRect(ix, iy, cellW, 3, bg)
text(ix + 1, iy, "[LUA]", C.accent, bg)
text(ix, iy + 1, shorten(item.label, cellW), C.text, bg)
text(ix, iy + 2, string.rep("-", cellW), C.muted, bg)
hit(ix, iy, cellW, 3, "desktopProgram", item)
end
end
if desktopState.notice and desktopState.notice ~= "" then
text(2, math.max(2, h - 1), shorten(desktopState.notice, math.max(1, w - 3)), C.yellow, C.wallpaper)
end
-- Bottom Cinnamon-like panel.
fillRect(1, h, w, 1, C.panel)
fillRect(1, h, 9, 1, C.green)
text(2, h, "ML " .. tr("menu"), C.black, C.green)
hit(1, h, 10, 1, "menu")
-- Taskbar tabs show application names. The active window appears too; clicking
-- its tab minimizes it, while minimized tabs restore the original window.
local clock = clockDisplay()
local clockX = math.max(1, w - ulen(clock) - 2)
local taskStart = 12
local taskEnd = clockX - 1
local taskSpace = math.max(0, taskEnd - taskStart + 1)
local tasks = {}
for _, item in ipairs(minimizedApps) do tasks[#tasks + 1] = {id = item.id, app = item.app, active = false} end
if currentApp then tasks[#tasks + 1] = {id = ensureWindowId(currentApp), app = currentApp, active = true} end
if #tasks > 0 and taskSpace >= 3 then
local capacity = math.max(1, math.floor(taskSpace / 3))
local firstIndex = math.max(1, #tasks - capacity + 1)
local count = #tasks - firstIndex + 1
local tabW = math.max(3, math.min(20, math.floor(taskSpace / count)))
local bx = taskStart
for i = firstIndex, #tasks do
if bx + tabW - 1 <= taskEnd then
local item = tasks[i]
local bg = item.active and C.title or C.panelAlt
local fg = item.active and C.green or C.text
fillRect(bx, h, tabW, 1, bg)
text(bx, h, shorten(taskbarCaption(item.app), tabW), fg, bg)
hit(bx, h, tabW, 1, item.active and "taskcurrent" or "restoremin", item.id)
bx = bx + tabW
end
end
end
text(clockX, h, clock, C.text, C.panel)
end
local function drawMenu()
local w, h = resolution()
local menuW = math.min(29, w - 2)
local menuH = math.min(12, math.max(6, h - 2))
local x, y = 1, math.max(1, h - menuH - 1)
fillRect(x, y, menuW, menuH, C.panelAlt)
fillRect(x, y, menuW, 2, C.title)
text(x + 1, y, shorten("MineLinux - " .. prefs.username, menuW - 2), C.green, C.title)
text(x + 1, y + 1, tr("applications"), C.muted, C.title)
local items = {
{"[>_] " .. tr("terminal"), "terminal"},
{"[DIR] " .. tr("file_manager"), "files"},
{"[APP] " .. tr("applications"), "launcher"},
{"[VM] " .. tr("virtual_machine"), "sandbox"},
{"[SYS] " .. tr("system_monitor"), "system"},
{"[SET] " .. tr("settings"), "settings"},
{"[@] " .. tr("user_account"), "account"},
{"[US] " .. tr("manage_users"), "users"},
{"[<>] " .. tr("switch_user"), "logout"},
{"[X] " .. tr("shutdown"), "shutdown"}
}
for i, item in ipairs(items) do
local yy = y + i + 1
if yy < h and yy <= y + menuH - 1 then
text(x + 1, yy, shorten(item[1], menuW - 2), C.text, C.panelAlt)
local action = item[2] == "shutdown" and "shutdown" or (item[2] == "logout" and "logout" or "app")
hit(x, yy, menuW, 1, action, item[2])
end
end
hit(x, y, menuW, 2, "menu")
end
local function drawShutdownConfirm()
local sw, sh = resolution()
local boxW = math.min(34, math.max(20, sw - 4))
local boxH = math.min(7, math.max(5, sh - 2))
local x = math.max(1, math.floor((sw - boxW) / 2) + 1)
local y = math.max(1, math.floor((sh - boxH) / 2) + 1)
fillRect(1, 1, sw, sh, C.wallpaper2)
hit(1, 1, sw, sh, "shutdownmodalblock")
fillRect(x, y, boxW, boxH, C.panel)
fillRect(x, y, boxW, 1, C.title)
text(x + 1, y, "MineLinux", C.green, C.title)
text(x + 2, y + 2, shorten(tr("shutdown_confirm"), boxW - 4), C.text, C.panel)
local buttonY = y + boxH - 2
local yesText = "[ " .. tr("shutdown_yes") .. " ]"
local noText = "[ " .. tr("cancel") .. " ]"
local yesW = math.min(ulen(yesText) + 2, boxW - 4)
local noW = math.min(ulen(noText) + 2, boxW - 4)
local yesX = x + 1
local noX = math.max(x + 1, x + boxW - noW - 1)
text(yesX, buttonY, shorten(yesText, yesW), C.black, C.green)
hit(yesX, buttonY, yesW, 1, "shutdownyes")
text(noX, buttonY, shorten(noText, noW), C.text, C.panelAlt)
hit(noX, buttonY, noW, 1, "shutdownno")
end
local function windowRect(target)
local sw, sh = resolution()
target = target or currentApp
if not target then return 1, 1, sw, math.max(1, sh - 1) end
local win = target.win
local workspaceH = math.max(1, sh - 1) -- leave the panel visible at all times
if not win then
local ww = math.min(math.max(24, math.floor(sw * 0.76)), math.max(1, sw - 2))
local hh = math.min(math.max(8, math.floor(workspaceH * 0.76)), math.max(1, workspaceH - 1))
win = {x = math.max(1, math.floor((sw - ww) / 2) + 1), y = math.max(1, math.floor((workspaceH - hh) / 2) + 1), w = ww, h = hh}
target.win = win
end
local minW, minH = math.min(sw, 24), math.min(workspaceH, 8)
win.w = math.min(sw, math.max(minW, math.floor(win.w or sw - 4)))
win.h = math.min(workspaceH, math.max(minH, math.floor(win.h or workspaceH - 2)))
win.x = math.max(1, math.min(sw - win.w + 1, math.floor(win.x or 1)))
win.y = math.max(1, math.min(workspaceH - win.h + 1, math.floor(win.y or 1)))
ensureWindowId(target)
appWindows[target.name] = {x = win.x, y = win.y, w = win.w, h = win.h, maximized = win.maximized == true}
if win.maximized then return 1, 1, sw, workspaceH end
return win.x, win.y, win.w, win.h
end
local function drawWindowFrame(title)
local x, y, w, h = windowRect(currentApp)
fillRect(x, y, w, h, C.window)
fillRect(x, y, w, 1, C.title)
local maximized = currentApp and currentApp.win and currentApp.win.maximized
-- Keep the title drag region clear of the three title-bar controls.
if not maximized and w > 15 then hit(x, y, w - 15, 1, "windowdrag") end
text(x + 1, y, shorten(title, math.max(1, w - 16)), C.text, C.title)
text(x + w - 12, y, "[-]", C.text, C.title)
text(x + w - 9, y, maximized and "[R]" or "[+]", C.text, C.title)
text(x + w - 6, y, "[X]", C.text, C.title)
hit(x + w - 12, y, 3, 1, "minimize")
hit(x + w - 9, y, 3, 1, "maximize")
hit(x + w - 6, y, 3, 1, "close")
if h > 2 then fillRect(x, y + h - 1, w, 1, C.panelAlt) end
if not maximized and w >= 24 and h >= 8 then
text(x + w - 2, y + h - 1, "+", C.accent, C.panelAlt)
hit(x + w - 3, y + h - 1, 3, 1, "windowresize")
end
return x, y, w, h
end
local function drawTerminal()
local x, y, w, h = drawWindowFrame(tr("terminal") .. " - root@minelinux")
local usableTop, usableBottom = y + 1, y + h - 2
local visible = math.max(1, usableBottom - usableTop)
local logCount = #currentApp.log
local start = math.max(1, logCount - visible + 1 - (currentApp.scroll or 0))
for i = 0, visible - 1 do
local item = currentApp.log[start + i]
if item then text(x + 1, usableTop + i, shorten(item.text, w - 2), item.color or C.text, C.window) end
end
local promptY = y + h - 2
local prefix = currentApp.writeMode and "write> " or ("root@minelinux:" .. currentApp.cwd .. "# ")
local fullInput = prefix .. currentApp.input
text(x + 1, promptY, shorten(fullInput, w - 2), C.green, C.window)
hit(x, y + 1, w, h - 2, "terminalfocus")
end
refreshEntries = function()
local entries = {}
currentApp.error = nil
local iter, err = filesystem.list(currentApp.path)
if iter then
for entry in iter do entries[#entries + 1] = entry end
table.sort(entries, function(a, b)
local ad, bd = a:sub(-1) == "/", b:sub(-1) == "/"
if ad ~= bd then return ad end
return a:lower() < b:lower()
end)
else
currentApp.error = tostring(err or "Could not list directory")
end
currentApp.entries = entries
currentApp.selected = math.max(1, math.min(tonumber(currentApp.selected) or 1, math.max(1, #entries)))
if currentApp.selectedPath then
if not filesystem.exists(currentApp.selectedPath) then
currentApp.selectedPath = nil
else
local found = false
for i, entry in ipairs(entries) do
local child = filesystem.canonical((currentApp.path == "/" and "" or currentApp.path) .. "/" .. entry:gsub("/$", ""))
if child == currentApp.selectedPath then currentApp.selected = i; found = true; break end
end
if not found then currentApp.selectedPath = nil end
end
end
end
local fileContextItems = {
{"Open", "open"}, {"New file", "newfile"}, {"New folder", "newdir"},
{"Rename", "rename"}, {"Copy", "copy"}, {"Cut", "cut"},
{"Paste", "paste"}, {"Delete", "delete"}, {"Properties", "properties"},
{"Refresh", "refresh"}, {"Desktop shortcut", "shortcut"}
}
local function pathBase(path)
path = tostring(path or ""):gsub("/$", "")
if path == "" then return "/" end
return path:match("([^/]+)$") or "/"
end
local function safeEntryName(name)
name = trim(name)
if name == "" or name == "." or name == ".." then return nil, "Name cannot be empty, '.' or '..'." end
if name:find("/", 1, true) or name:find("\\", 1, true) then return nil, "Use a name, not a path." end
if name:find("[%c]") then return nil, "Control characters are not allowed." end
return name
end
local function selectedFilePath()
if currentApp and currentApp.name == "files" then
local path = currentApp.selectedPath
if path and filesystem.exists(path) then return path end
end
return nil
end
local function fileNotice(message, color)
if currentApp and currentApp.name == "files" then
currentApp.notice = tostring(message or "")
currentApp.noticeColor = color or C.muted
end
end
local function drawFileContextMenu(sw, sh)
if not currentApp.contextMenu then return end
local visibleItems = {}
local selected = selectedFilePath()
local selectedIsLua = selected and not filesystem.isDirectory(selected) and selected:lower():match("%.lua$")
for _, item in ipairs(fileContextItems) do
if item[2] ~= "shortcut" or selectedIsLua then visibleItems[#visibleItems + 1] = item end
end
local menuW, menuH = 22, #visibleItems + 1
menuW = math.min(menuW, math.max(10, sw - 2))
menuH = math.min(menuH, math.max(3, sh - 2))
local mx = math.max(1, math.min(sw - menuW + 1, currentApp.contextMenu.x or 2))
local my = math.max(1, math.min(sh - menuH, currentApp.contextMenu.y or 2))
fillRect(mx, my, menuW, menuH, C.panelAlt)
fillRect(mx, my, menuW, 1, C.title)
text(mx + 1, my, tr("file_actions"), C.accent, C.title)
hit(mx, my, menuW, 1, "filecontextclose")
for i = 1, math.min(#visibleItems, menuH - 1) do
local item = visibleItems[i]
local label = item[2] == "shortcut" and tr("desktop_shortcut") or item[1]
text(mx + 1, my + i, shorten(label, menuW - 2), C.text, C.panelAlt)
hit(mx, my + i, menuW, 1, "fileaction", item[2])
end
end
local function drawFiles()
local x, y, w, h = drawWindowFrame(tr("file_manager") .. " - MineLinux")
local sw, sh = resolution()
text(x + 1, y + 1, "[ " .. tr("up") .. " ]", C.yellow, C.window)
hit(x + 1, y + 1, 6, 1, "fileparent")
text(x + 8, y + 1, shorten(currentApp.path, math.max(1, w - 10)), C.green, C.window)
hit(x + 8, y + 1, math.max(1, w - 10), 1, "filepath")
-- Compact toolbar fits low-resolution OC screens; More exposes the full context menu.
local toolbar = {
{"N", "newfile"}, {"D", "newdir"}, {"R", "rename"}, {"X", "delete"},
{"C", "copy"}, {"K", "cut"}, {"V", "paste"}, {"...", "context"}
}
local bx = x + 1
for _, item in ipairs(toolbar) do
local label = "[" .. item[1] .. "]"
local bw = #label
if bx + bw - 1 <= x + w - 2 then
fillRect(bx, y + 2, bw, 1, C.panelAlt)
text(bx, y + 2, label, item[2] == "delete" and C.red or C.accent, C.panelAlt)
hit(bx, y + 2, bw, 1, item[2] == "context" and "filecontext" or "fileaction", item[2])
bx = bx + bw + 1
end
end
local sidebarW = w >= 62 and 13 or 0
local listX, listW = x + 1, w - 2
local listTop = y + 3
if sidebarW > 0 then
listW = w - sidebarW - 3
listX = x + sidebarW + 1
fillRect(x + 1, listTop, sidebarW - 1, math.max(1, h - 5), C.panelAlt)
text(x + 2, listTop, tr("places"), C.green, C.panelAlt)
local places = {
{tr("home"), "/home/MineLinux"}, {tr("desktop_place"), "/home/MineLinux/Desktop"},
{tr("documents"), "/home/MineLinux/Documents"}, {tr("downloads"), "/home/MineLinux/Downloads"},
{tr("pictures"), "/home/MineLinux/Pictures"}, {tr("root"), "/"}
}
for i, place in ipairs(places) do
local py = listTop + i
if py < y + h - 1 then
local selected = currentApp.path == place[2]
text(x + 2, py, shorten(place[1], sidebarW - 3), selected and C.accent or C.text, selected and C.title or C.panelAlt)
hit(x + 1, py, sidebarW - 1, 1, "filesnav", place[2])
end
end
fillRect(x + sidebarW, listTop, 1, math.max(1, h - 5), C.title)
end
local contentRows = math.max(0, h - 5)
for i = 1, math.min(#currentApp.entries, contentRows) do
local entry = currentApp.entries[i]
local name = entry:gsub("/$", "")
local child = filesystem.canonical((currentApp.path == "/" and "" or currentApp.path) .. "/" .. name)
local isDir = filesystem.isDirectory(child)
local selected = currentApp.selectedPath == child
if selected then fillRect(listX, listTop + i - 1, math.max(1, listW), 1, C.title) end
local marker = isDir and "[DIR] " or "[FILE] "
text(listX + 1, listTop + i - 1, shorten(marker .. name, math.max(1, listW - 2)), isDir and C.yellow or C.text, selected and C.title or C.window)
hit(listX, listTop + i - 1, math.max(1, listW), 1, "fileentry", child)
end
if #currentApp.entries == 0 and not currentApp.error then
text(listX + 1, listTop + 1, tr("empty_folder"), C.muted, C.window)
end
if currentApp.error then text(x + 1, y + h - 2, shorten(currentApp.error, w - 2), C.red, C.window) end
if currentApp.inputMode then
local mode = currentApp.inputMode
local prompt = mode.kind == "delete" and "Type YES to delete: " or (mode.kind == "rename" and "New name: " or (mode.kind == "newdir" and "Folder name: " or "File name: "))
fillRect(x + 1, y + h - 2, math.max(1, w - 2), 1, C.panelAlt)
text(x + 1, y + h - 2, shorten(prompt .. (mode.buffer or ""), w - 2), C.accent, C.panelAlt)
else
local status = currentApp.notice or (fileClipboard and ("Clipboard: " .. fileClipboard.mode .. " " .. pathBase(fileClipboard.path)) or "Enter open | right-click / ... actions")
text(x + 1, y + h - 2, shorten(status, w - 2), currentApp.noticeColor or C.muted, C.panelAlt)
end
text(x + 1, y + h - 1, "N new D folder R rename X delete C copy K cut V paste", C.muted, C.panelAlt)
drawFileContextMenu(sw, sh)
end
local function drawFileView()
local x, y, w, h = drawWindowFrame(tr("text_viewer") .. " - " .. currentApp.path)
local file = io.open(currentApp.path, "r")
local contents = ""
if file then contents = file:read("*a") or ""; file:close() end
local row = y + 1
for line in (contents .. "\n"):gmatch("(.-)\n") do
if row >= y + h - 2 then break end
text(x + 1, row, shorten(line, w - 2), C.text, C.window)
row = row + 1
end
if contents == "" then text(x + 1, row, tr("empty_file"), C.muted, C.window) end
text(x + 1, y + h - 1, tr("close_return"), C.muted, C.panelAlt)
end
local function drawSystem()
local x, y, w, h = drawWindowFrame(tr("system_monitor"))
local total, free = computer.totalMemory(), computer.freeMemory()
local n = 0
for _ in component.list() do n = n + 1 end
local rows = {
{tr("distribution"), "MineLinux " .. VERSION},
{tr("desktop"), "Cinnamon Desktop"},
{tr("base_system"), "OpenOS"},
{tr("minecraft"), "1.7.10 / GTNH"},
{tr("memory_used"), tostring(total - free) .. " / " .. tostring(total) .. " B"},
{tr("memory_free"), tostring(free) .. " B"},
{tr("uptime"), string.format("%.1f sec", computer.uptime())},
{tr("components"), tostring(n)},
{tr("screen"), tostring(select(1, resolution())) .. " x " .. tostring(select(2, resolution()))},
{tr("date"), os.date("%Y-%m-%d %H:%M:%S")},
{tr("bios_profile"), tostring(biosStatus)},
{tr("bios_label"), tostring(biosLabelStatus)}
}
for i, row in ipairs(rows) do
local yy = y + i + 1
if yy < y + h - 1 then
text(x + 2, yy, shorten(row[1] .. ":", 17), C.green, C.window)
text(x + 20, yy, shorten(row[2], w - 22), C.text, C.window)
end
end
end
local function readableBytes(value)
value = tonumber(value)
if not value or value ~= value or value < 0 or value == math.huge then return "?" end
local units = {"B", "KiB", "MiB", "GiB", "TiB"}
local n, unit = value, 1
while n >= 1024 and unit < #units do n, unit = n / 1024, unit + 1 end
if unit == 1 then return string.format("%d %s", math.floor(n), units[unit]) end
return string.format("%.1f %s", n, units[unit])
end
-- Storage capacity comes from the mounted filesystem backing /home (or /).
-- Component proxies and the filesystem library vary slightly between OC builds,
-- so each API read is guarded and the UI remains usable if a drive lacks stats.
local function storageStats()
local function valid(total, used)
total, used = tonumber(total), tonumber(used)
if not total or not used or total ~= total or used ~= used then return nil end
if total <= 0 or total == math.huge or used == math.huge then return nil end
used = math.max(0, math.min(total, used))
return total, used
end
local paths = {"/home", "/"}
for _, path in ipairs(paths) do
if type(filesystem.get) == "function" then
local ok, proxy = pcall(filesystem.get, path)
if ok and proxy then
local okTotal, total = pcall(function() return proxy.spaceTotal() end)
local okUsed, used = pcall(function() return proxy.spaceUsed() end)
if okTotal and okUsed then
local t, u = valid(total, used)
if t then return t, u end
end
end
end
if type(filesystem.spaceTotal) == "function" and type(filesystem.spaceUsed) == "function" then
local okTotal, total = pcall(filesystem.spaceTotal, path)
local okUsed, used = pcall(filesystem.spaceUsed, path)
if okTotal and okUsed then
local t, u = valid(total, used)
if t then return t, u end
end
end
end
return nil, nil
end
local function drawSettings()
local x, y, w, h = drawWindowFrame(tr("settings_title"))
local tabs = {"appearance", "clock", "language", "account", "system", "memory"}
local active = currentApp.settingsTab or "appearance"
local labels = {
appearance = tr("appearance"), clock = tr("clock"), language = tr("language"),
account = tr("account"), system = tr("system"), memory = tr("memory")
}
local sidebarW = math.max(10, math.min(14, math.floor(w * 0.36)))
fillRect(x + 1, y + 1, sidebarW - 1, math.max(1, h - 2), C.panelAlt)
fillRect(x + sidebarW, y + 1, 1, math.max(1, h - 2), C.title)
for i, tab in ipairs(tabs) do
local row = y + 2 + i
if row < y + h - 2 then
local selected = active == tab
if selected then fillRect(x + 1, row, sidebarW - 1, 1, C.title) end
text(x + 2, row, shorten(labels[tab], sidebarW - 3), selected and C.accent or C.text, selected and C.title or C.panelAlt)
hit(x + 1, row, sidebarW - 1, 1, "settingtab", tab)
end
end
local cx = x + sidebarW + 2
local cw = math.max(1, x + w - 2 - cx)
text(cx, y + 2, shorten(labels[active] or tr("appearance"), cw), C.green, C.window)
local row = y + 4
local bottom = y + h - 3
local function settingLine(label, action, value, color)
if row <= bottom then
text(cx, row, shorten(label, cw), color or C.text, C.window)
if action then hit(cx, row, cw, 1, action, value) end
row = row + 1
end
end
local onOff = function(v) return v and tr("enabled") or tr("disabled") end
if active == "appearance" then
settingLine(tr("theme") .. ": " .. C.name, "theme")
settingLine(tr("wallpaper") .. ": " .. (wallpaperNames[prefs.wallpaper] or wallpaperNames[1]), "wallpaper")
settingLine(tr("reset_windows"), "resetwins", nil, C.yellow)
settingLine(tr("theme_hint"))
elseif active == "clock" then
settingLine(tr("real_life_clock") .. ": " .. onOff(prefs.realLifeClock ~= false), "clockreal", nil, C.accent)
settingLine(tr("clock_format") .. ": " .. (prefs.clock24 and tr("hour24") or tr("hour12")), "clockformat")
settingLine(tr("show_date") .. ": " .. onOff(prefs.showDate ~= false), "showdate")
local preview = os.date(prefs.clock24 and "%H:%M" or "%I:%M %p")
if prefs.showDate then preview = os.date("%d.%m") .. " " .. preview end
settingLine(tr("live_preview") .. ": " .. preview, nil, nil, C.green)
settingLine(prefs.realLifeClock ~= false and tr("system_clock") or tr("session_clock"), nil, nil, C.muted)
elseif active == "language" then
settingLine(tr("interface_language") .. ": " .. (prefs.language == "ru" and tr("russian") or tr("english")), "language", nil, C.accent)
settingLine(tr("switch_language"), "language")
settingLine("EN / RU", "language")
settingLine(tr("settings_saved"), nil, nil, C.muted)
elseif active == "account" then
settingLine(tr("user_profile") .. ": " .. shorten(prefs.username, math.max(1, cw - ulen(tr("user_profile")) - 2)))
settingLine(tr("avatar") .. ": [" .. tostring(prefs.avatar or "@") .. "]")
settingLine(tr("account_settings"), "account", nil, C.accent)
settingLine(tr("manage_users") .. " (" .. tostring(#users) .. ")", "users", nil, C.accent)
settingLine(tr("startup_lock") .. ": " .. onOff((prefs.passwordHash or "") ~= ""), nil)
elseif active == "memory" then
local diskTotal, diskUsed = storageStats()
local ramTotal, ramFree = 0, 0
pcall(function() ramTotal = tonumber(computer.totalMemory()) or 0 end)
pcall(function() ramFree = tonumber(computer.freeMemory()) or 0 end)
ramTotal = math.max(0, ramTotal)
ramFree = math.max(0, math.min(ramTotal, ramFree))
local function percent(used, total)
if not total or total <= 0 then return nil end
return math.max(0, math.min(100, math.floor((used / total) * 100 + 0.5)))
end
local function usageBlock(label, total, used, color, unavailable)
if row <= bottom then
local pct = percent(used or 0, total or 0)
local titleText = label .. " " .. (pct and (tostring(pct) .. "%") or "?")
text(cx, row, shorten(titleText, cw), C.green, C.window)
row = row + 1
if row <= bottom then
fillRect(cx, row, cw, 1, C.panelAlt)
if pct then
local filled = math.floor(cw * pct / 100 + 0.5)
if pct > 0 then filled = math.max(1, filled) end
if filled > 0 then fillRect(cx, row, math.min(cw, filled), 1, color) end
end
row = row + 1
end
if row <= bottom then
local detail
if pct then
detail = readableBytes(used) .. " / " .. readableBytes(total)
else
detail = unavailable or tr("storage_unavailable")
end
text(cx, row, shorten(detail, cw), C.muted, C.window)
row = row + 1
end
end
end
usageBlock(tr("storage"), diskTotal, diskUsed, C.accent, tr("storage_unavailable"))
usageBlock(tr("ram"), ramTotal, ramTotal - ramFree, C.blue, tr("memory_free") .. ": ?")
else
settingLine("MineLinux " .. VERSION)
settingLine(tr("base_system") .. ": OpenOS")
settingLine(tr("screen") .. ": " .. tostring(select(1, resolution())) .. "x" .. tostring(select(2, resolution())))
settingLine(tr("config_file") .. ": " .. settingsPath)
settingLine(tr("reset_layout"), "resetwins", nil, C.yellow)
end
local status = currentApp.notice or tr("settings_saved")
text(x + 2, y + h - 2, shorten(status, w - 4), currentApp.noticeColor or C.muted, C.panelAlt)
end
local function drawAccount()
local x, y, w, h = drawWindowFrame(tr("user_account"))
local form = currentApp.form or {username = prefs.username, avatar = prefs.avatar, pass1 = "", pass2 = ""}
currentApp.form = form
text(x + 2, y + 2, tr("account_profile"), C.green, C.window)
text(x + 2, y + 3, tr("profile_name") .. ":", C.text, C.window)
local nameBg = currentApp.editField == "username" and C.title or C.panelAlt
fillRect(x + 9, y + 3, math.max(2, w - 12), 1, nameBg)
text(x + 10, y + 3, shorten(form.username, math.max(1, w - 13)), C.text, nameBg)
hit(x + 8, y + 3, math.max(1, w - 10), 1, "accountfield", "username")
text(x + 2, y + 4, tr("avatar") .. ":", C.text, C.window)
local avatarBg = currentApp.editField == "avatar" and C.title or C.panelAlt
fillRect(x + 9, y + 4, math.max(2, w - 12), 1, avatarBg)
text(x + 10, y + 4, "[" .. shorten(form.avatar, 2) .. "] " .. tr("type_avatar"), C.accent, avatarBg)
hit(x + 8, y + 4, math.max(1, w - 10), 1, "accountfield", "avatar")
text(x + 2, y + 5, tr("password") .. ":", C.text, C.window)
local passBg = currentApp.editField == "pass1" and C.title or C.panelAlt
fillRect(x + 12, y + 5, math.max(2, w - 15), 1, passBg)
text(x + 13, y + 5, shorten(string.rep("*", #form.pass1), math.max(1, w - 16)), C.text, passBg)
hit(x + 11, y + 5, math.max(1, w - 13), 1, "accountfield", "pass1")
text(x + 2, y + 6, "Confirm:", C.text, C.window)
local confirmBg = currentApp.editField == "pass2" and C.title or C.panelAlt
fillRect(x + 12, y + 6, math.max(2, w - 15), 1, confirmBg)
text(x + 13, y + 6, shorten(string.rep("*", #form.pass2), math.max(1, w - 16)), C.text, confirmBg)
hit(x + 11, y + 6, math.max(1, w - 13), 1, "accountfield", "pass2")
text(x + 2, y + 7, tr("saved_password") .. ": " .. (prefs.passwordHash ~= "" and tr("yes") or tr("no")), C.muted, C.window)
text(x + 2, y + 8, "[ " .. tr("save") .. " ]", C.green, C.window)
hit(x + 2, y + 8, 8, 1, "accountsave")
text(x + 13, y + 8, "[ " .. tr("clear") .. " ]", C.yellow, C.window)
hit(x + 13, y + 8, 9, 1, "accountclearpass")
text(x + 24, y + 8, "[Users]", C.accent, C.window)
hit(x + 24, y + 8, 7, 1, "users")
local statusY = math.min(y + 9, y + h - 3)
if currentApp.notice then
text(x + 2, statusY, shorten(currentApp.notice, w - 4), currentApp.noticeColor or C.muted, C.window)
else
text(x + 2, statusY, tr("click_field"), C.muted, C.window)
end
text(x + 2, y + h - 2, tr("enter_next"), C.muted, C.panelAlt)
end
local loginLocked = true
local loginInput = ""
local loginMessage = ""
local function drawLogin()
local w, h = resolution()
fillRect(1, 1, w, h, C.wallpaper)
if authMode == "setup" and setupState then
local boxH = math.min(14, h)
local boxW = math.min(math.max(34, math.floor(w * 0.82)), w)
local boxX = math.max(1, math.floor((w - boxW) / 2) + 1)
local boxY = math.max(1, math.floor((h - boxH) / 2) + 1)
fillRect(boxX, boxY, boxW, boxH, C.panel)
fillRect(boxX, boxY, boxW, 2, C.title)
text(boxX + 2, boxY, tr("setup_title") .. " - MineLinux " .. VERSION, C.green, C.title)
text(boxX + 2, boxY + 2, shorten(tr("setup_subtitle"), boxW - 4), C.text, C.panel)
local st = setupState
local fields = {
{"username", tr("setup_name")}, {"avatar", tr("setup_avatar")},
{"pass1", tr("setup_password")}, {"pass2", tr("setup_confirm")}
}
for i, item in ipairs(fields) do
local yy = boxY + 4 + i
if yy < boxY + boxH - 2 then
local selected = (st.field or 1) == i
local bg = selected and C.title or C.panelAlt
text(boxX + 2, yy, shorten(item[2] .. ":", 13), selected and C.accent or C.muted, C.panel)
local shown = st[item[1]] or ""
if item[1] == "pass1" or item[1] == "pass2" then shown = string.rep("*", #shown) end
fillRect(boxX + 16, yy, math.max(1, boxW - 18), 1, bg)
text(boxX + 17, yy, shorten(shown, boxW - 19), C.text, bg)
hit(boxX + 16, yy, math.max(1, boxW - 18), 1, "setupfield", i)
end
end
local optionY = boxY + 10
if optionY < boxY + boxH - 1 then
text(boxX + 2, optionY, tr("setup_language") .. ": " .. (st.language == "ru" and "RU" or "EN"), C.accent, C.panel)
hit(boxX + 2, optionY, math.max(9, math.floor(boxW / 3)), 1, "setuplang")
text(boxX + math.floor(boxW / 2), optionY, tr("setup_theme") .. ": " .. (themes[st.theme] and themes[st.theme].name or themes[1].name), C.accent, C.panel)
hit(boxX + math.floor(boxW / 2), optionY, math.max(1, boxW - math.floor(boxW / 2) - 2), 1, "setuptheme")
end
if boxH >= 13 then text(boxX + 2, boxY + boxH - 3, shorten(tr("setup_instructions"), boxW - 4), C.muted, C.panel) end
text(boxX + 2, boxY + boxH - 2, shorten(st.notice or loginMessage or "", boxW - 4), st.noticeColor or C.yellow, C.panel)
text(boxX + 2, boxY + boxH - 1, "[ CREATE / SAVE ]", C.green, C.panel)
hit(boxX + 2, boxY + boxH - 1, math.min(18, boxW - 4), 1, "setupfinish")
return
end
local boxH = math.min(13, h)
local boxW = math.min(40, w)
local boxX = math.max(1, math.floor((w - boxW) / 2) + 1)
local boxY = math.max(1, math.floor((h - boxH) / 2) + 1)
fillRect(boxX, boxY, boxW, boxH, C.panel)
fillRect(boxX, boxY, boxW, 2, C.title)
text(boxX + 2, boxY, tr("login"), C.green, C.title)
text(boxX + 2, boxY + 2, tr("select_account") .. " (" .. tostring(#users) .. " " .. tr("user_count") .. ")", C.muted, C.panel)
local user = users[selectedLoginIndex]
if user then
text(boxX + 2, boxY + 4, "< " .. tostring(user.avatar or "@") .. " " .. shorten(user.username, boxW - 10) .. " >", C.accent, C.panelAlt)
hit(boxX + 2, boxY + 4, boxW - 4, 1, "loginnext")
text(boxX + 2, boxY + 6, (user.passwordHash ~= "" and (tr("password") .. ":") or tr("user_no_password")), C.muted, C.panel)
fillRect(boxX + 13, boxY + 6, math.max(1, boxW - 16), 1, C.panelAlt)
text(boxX + 14, boxY + 6, string.rep("*", #loginInput), C.text, C.panelAlt)
hit(boxX + 13, boxY + 6, math.max(1, boxW - 16), 1, "loginpass")
text(boxX + 2, boxY + 8, "Up/Down or click: " .. tr("select_account"), C.muted, C.panel)
text(boxX + 2, boxY + 9, "Enter: " .. tr("unlock"), C.accent, C.panel)
else
text(boxX + 2, boxY + 5, "No users. Restart MineLinux to run setup.", C.red, C.panel)
end
text(boxX + 2, boxY + boxH - 2, shorten(loginMessage, boxW - 4), loginMessage ~= "" and C.red or C.muted, C.panel)
text(boxX + 2, boxY + boxH - 1, "MineLinux " .. VERSION .. " | OpenOS base", C.muted, C.panel)
end
local function drawUsers()
local x, y, w, h = drawWindowFrame(tr("users_title"))
local mode = currentApp.usersMode or "list"
if mode == "new" then
local form = currentApp.userForm or {username = "", avatar = "@", pass1 = "", pass2 = ""}
currentApp.userForm = form
text(x + 2, y + 2, tr("create_account"), C.green, C.window)
local fields = {{"username", tr("setup_name")}, {"avatar", tr("setup_avatar")}, {"pass1", tr("setup_password")}, {"pass2", tr("setup_confirm")}}
for i, field in ipairs(fields) do
local yy = y + 3 + i
if yy < y + h - 3 then
local selected = currentApp.editField == field[1]
local bg = selected and C.title or C.panelAlt
text(x + 2, yy, shorten(field[2] .. ":", 12), C.muted, C.window)
local value = form[field[1]] or ""
if field[1] == "pass1" or field[1] == "pass2" then value = string.rep("*", #value) end
fillRect(x + 15, yy, math.max(1, w - 18), 1, bg)
text(x + 16, yy, shorten(value, w - 19), C.text, bg)
hit(x + 15, yy, math.max(1, w - 18), 1, "usernewfield", field[1])
end
end
text(x + 2, y + h - 3, "[ Save ] [ Cancel ]", C.green, C.window)
hit(x + 2, y + h - 3, 8, 1, "usersaves")
hit(x + 12, y + h - 3, 10, 1, "usercancel")
text(x + 2, y + h - 2, shorten(currentApp.notice or "Enter: next/save | Esc: cancel", w - 4), currentApp.noticeColor or C.muted, C.panelAlt)
else
text(x + 2, y + 2, tr("manage_users") .. ": " .. tostring(#users), C.green, C.window)
local firstRow, lastRow = y + 3, y + h - 4
local visibleCount = math.max(0, lastRow - firstRow + 1)
local startIndex = math.max(1, (currentApp.userSelected or 1) - math.max(0, visibleCount - 1))
for offset = 0, visibleCount - 1 do
local i = startIndex + offset
local user = users[i]
if user then
local yy = firstRow + offset
local selected = currentApp.userSelected == i
local bg = selected and C.title or C.window
if selected then fillRect(x + 1, yy, w - 2, 1, bg) end
local suffix = (i == activeUserIndex and " *" or "") .. (user.passwordHash ~= "" and " [P]" or " [--]")
local line = tostring(i) .. ". " .. tostring(user.avatar or "@") .. " " .. tostring(user.username) .. suffix
text(x + 2, yy, shorten(line, w - 4), selected and C.accent or C.text, bg)
hit(x + 1, yy, w - 2, 1, "userselect", i)
end
end
text(x + 2, y + h - 3, "[N] " .. tr("add_user") .. " [D] " .. tr("delete_user"), C.accent, C.window)
hit(x + 2, y + h - 3, math.min(12, w - 4), 1, "usernew")
hit(x + 15, y + h - 3, math.min(14, w - 17), 1, "userdelete")
text(x + 2, y + h - 2, shorten(currentApp.notice or "Enter: switch account | Esc: back", w - 4), currentApp.noticeColor or C.muted, C.panelAlt)
end
end
local function finishInitialSetup()
local st = setupState
if not st then return false end
local name = sanitizeSetting((st.username or ""):match("^%s*(.-)%s*$"), 24)
if name == "" then st.notice = tr("setup_name") .. " cannot be empty."; st.noticeColor = C.red; return false end
if accountNameExists(name) then st.notice = tr("duplicate_user"); st.noticeColor = C.red; return false end
if (st.pass1 or "") ~= (st.pass2 or "") then st.notice = "Passwords do not match."; st.noticeColor = C.red; return false end
local avatar = sanitizeSetting((st.avatar or "@"):sub(1, 2), 2)
if avatar == "" then avatar = "@" end
prefs.language = st.language == "ru" and "ru" or "en"
prefs.theme = math.max(1, math.min(#themes, tonumber(st.theme) or 1))
themeIndex = prefs.theme; setTheme()
users[1] = {username = name, avatar = avatar, passwordHash = (st.pass1 ~= "" and hashPassword(st.pass1) or "")}
activeUserIndex, selectedLoginIndex = 1, 1
prefs.username, prefs.avatar, prefs.passwordHash = users[1].username, users[1].avatar, users[1].passwordHash
prefs.lockAtStartup = prefs.passwordHash ~= ""
local ok, err = saveUsers()
if not ok then st.notice = "Could not save accounts: " .. tostring(err); st.noticeColor = C.red; users = {}; return false end
local prefOk, prefErr = savePrefs()
if not prefOk then st.notice = "Could not save settings: " .. tostring(prefErr); st.noticeColor = C.red; return false end
firstRun = false; authMode = "login"; loginLocked = true; loginInput = ""; loginMessage = tr("setup_done") .. " - sign in"; setupState = nil
return true
end
local function activateUser(index)
local user = users[index]
if not user then return false end
activeUserIndex = index
selectedLoginIndex = index
prefs.username, prefs.avatar, prefs.passwordHash = user.username, user.avatar, user.passwordHash
prefs.lockAtStartup = user.passwordHash ~= ""
savePrefs()
loginInput, loginMessage = "", ""
loginLocked, authMode = false, "login"
currentApp, menuOpen = nil, false
return true
end
local function drawAbout()
local x, y, w, h = drawWindowFrame(tr("about"))
text(x + 2, y + 2, "MineLinux " .. VERSION, C.green, C.window)
text(x + 2, y + 4, tr("about_line"), C.text, C.window)
text(x + 2, y + 5, tr("about_gtnh"), C.text, C.window)
text(x + 2, y + 7, tr("return_note"), C.muted, C.window)
end
local function drawTrash()
local x, y, w, h = drawWindowFrame(tr("trash"))
text(x + 2, y + 2, tr("trash_empty"), C.green, C.window)
text(x + 2, y + 4, tr("trash_note"), C.text, C.window)
text(x + 2, y + 5, "Use care with rm in Terminal.", C.muted, C.window)
end
local function drawLauncher()
local x, y, w, h = drawWindowFrame(tr("app_center"))
text(x + 2, y + 2, tr("app_hint"), C.muted, C.window)
local apps = {
{"[TXT]", tr("notepad"), "notepad", C.green},
{"[ART]", tr("paint"), "paint", C.accent},
{"[123]", tr("calculator"), "calculator", C.blue},
{"[CAL]", tr("calendar"), "calendar", C.yellow},
{"[VM]", tr("virtual_machine"), "sandbox", C.green}
}
local colW = math.max(10, math.floor((w - 4) / 2))
for i, item in ipairs(apps) do
local col = (i - 1) % 2
local row = math.floor((i - 1) / 2)
local bx = x + 2 + col * colW
local by = y + 4 + row * 3
if by < y + h - 2 then
fillRect(bx, by, math.max(8, colW - 2), 2, C.panelAlt)
text(bx + 1, by, item[1], item[4], C.panelAlt)
text(bx + 1, by + 1, shorten(item[2], colW - 4), C.text, C.panelAlt)
hit(bx, by, math.max(8, colW - 2), 2, "launchapp", item[3])
end
end
text(x + 2, y + h - 2, "ESC: " .. tr("close_return"), C.muted, C.panelAlt)
end
local function drawSandbox()
local x, y, w, h = drawWindowFrame(tr("vm_title"))
text(x + 2, y + 2, tr("vm_title") .. " / " .. tr("vmfs_title"), C.green, C.window)
text(x + 2, y + 3, "Root: / -> " .. VMFS_ROOT, C.accent, C.window)
text(x + 2, y + 4, tr("vm_instructions"), C.text, C.window)
text(x + 2, y + 5, tr("vm_isolated"), C.muted, C.window)
text(x + 2, y + 6, tr("vm_persistent"), C.muted, C.window)
local entries = vmList("/") or {}
text(x + 2, y + 8, "Virtual /", C.green, C.window)
local row = y + 9
for _, entry in ipairs(entries) do
if row >= y + h - 2 then break end
text(x + 3, row, shorten(entry, w - 6), C.text, C.window)
row = row + 1
end
if #entries == 0 then text(x + 3, row, "(empty virtual filesystem)", C.muted, C.window) end
text(x + 2, y + h - 2, "vmfs ls / | vmfs mkdir /folder | vmfs write /file.txt text", C.accent, C.panelAlt)
end
local function lineList(contents)
local lines = {}
contents = tostring(contents or "")
for line in (contents .. "\n"):gmatch("(.-)\n") do lines[#lines + 1] = line end
if #lines == 0 then lines[1] = "" end
return lines
end
local function drawNotepad()
local x, y, w, h = drawWindowFrame(tr("notepad") .. " - " .. pathBase(currentApp.path) .. (currentApp.modified and " *" or ""))
text(x + 1, y + 1, "[N]ew", C.green, C.window); hit(x + 1, y + 1, 5, 1, "notepadaction", "new")
text(x + 7, y + 1, "[O]pen", C.green, C.window); hit(x + 7, y + 1, 6, 1, "notepadaction", "open")
text(x + 14, y + 1, "[S]ave", C.green, C.window); hit(x + 14, y + 1, 6, 1, "notepadaction", "save")
text(x + 21, y + 1, "[A]s", C.green, C.window); hit(x + 21, y + 1, 4, 1, "notepadaction", "saveas")
if currentApp.pathMode then
local label = currentApp.pathMode == "open" and tr("open_path") or tr("save_path")
text(x + 1, y + 2, shorten(label .. ": " .. (currentApp.pathInput or ""), w - 2), C.accent, C.window)
text(x + 1, y + 3, "Enter=OK Esc=Cancel", C.muted, C.window)
else
local lines = lineList(currentApp.contents)
local available = math.max(1, h - 5)
local start = math.max(1, math.min(currentApp.scroll or 1, math.max(1, #lines - available + 1)))
currentApp.scroll = start
for i = 1, available do
local source = lines[start + i - 1]
if source then text(x + 1, y + 2 + i, shorten(source, w - 2), C.text, C.window) end
end
hit(x + 1, y + 2, w - 2, math.max(1, h - 4), "notepadfocus")
end
local status = (currentApp.path or "") .. (currentApp.modified and " * " .. tr("modified") or " | " .. tr("saved"))
if currentApp.notice then status = currentApp.notice end
text(x + 1, y + h - 1, shorten(status, w - 2), currentApp.notice and C.yellow or C.muted, C.panelAlt)
end
local function paintPalette()
return {C.text, C.green, C.red, C.yellow, C.blue, C.accent, C.muted, C.black}
end
local function savePaintFile()
local appState = currentApp
local width = math.max(1, tonumber(appState.canvasWidth) or 1)
local height = math.max(1, tonumber(appState.canvasHeight) or 1)
local output = {"MINEPAINT1", tostring(width) .. "," .. tostring(height)}
for cy = 1, height do
local row = {}
for cx = 1, width do row[#row + 1] = tostring(appState.canvas[cx .. ":" .. cy] or 0) end
output[#output + 1] = table.concat(row)
end
local ok, err = writeTextSafe(appState.savePath, table.concat(output, "\n") .. "\n")
appState.notice = ok and (tr("paint_saved") .. ": " .. appState.savePath) or ("Save failed: " .. tostring(err))
appState.noticeColor = ok and C.green or C.red
end
local function loadPaintFile()
local data = readTextSafe(currentApp.savePath)
if not data or not data:match("^MINEPAINT1\n") then
currentApp.notice = tr("paint_empty")
currentApp.noticeColor = C.yellow
return
end
local rest = data:gsub("^MINEPAINT1\n", "", 1)
local firstEnd = rest:find("\n", 1, true)
if not firstEnd then currentApp.notice = "Invalid drawing file."; currentApp.noticeColor = C.red; return end
local width, height = rest:sub(1, firstEnd - 1):match("^(%d+),(%d+)$")
width, height = tonumber(width), tonumber(height)
if not width or not height or width < 1 or width > 200 or height < 1 or height > 100 then
currentApp.notice = "Invalid drawing dimensions."; currentApp.noticeColor = C.red; return
end
currentApp.canvas = {}
local rowsText = rest:sub(firstEnd + 1)
local rowIndex = 0
for row in (rowsText .. "\n"):gmatch("(.-)\n") do
rowIndex = rowIndex + 1
if rowIndex > height then break end
for cx = 1, math.min(width, #row) do
local color = tonumber(row:sub(cx, cx)) or 0
if color > 0 then currentApp.canvas[cx .. ":" .. rowIndex] = math.min(8, color) end
end
end
currentApp.canvasWidth, currentApp.canvasHeight = width, height
currentApp.notice, currentApp.noticeColor = tr("paint_loaded"), C.green
end
local function drawPaint()
local x, y, w, h = drawWindowFrame(tr("paint"))
text(x + 1, y + 1, "[Save]", C.green, C.window); hit(x + 1, y + 1, 6, 1, "paintaction", "save")
text(x + 8, y + 1, "[Load]", C.green, C.window); hit(x + 8, y + 1, 6, 1, "paintaction", "load")
text(x + 16, y + 1, "[Clear]", C.red, C.window); hit(x + 16, y + 1, 7, 1, "paintaction", "clear")
local palette = paintPalette()
local paletteY = y + 2
local sx = x + 2
for i, color in ipairs(palette) do
local px = sx + (i - 1) * 3
if px < x + w - 2 then
fillRect(px, paletteY, 2, 1, color)
text(px, paletteY, " ", C.text, color)
hit(px, paletteY, 2, 1, "paintcolor", i)
end
end
local startX, startY = x + 2, y + 4
local cw = math.max(1, w - 4)
local ch = math.max(1, h - 6)
currentApp.canvasWidth, currentApp.canvasHeight = cw, ch
for cy = 1, ch do
for cx = 1, cw do
local col = currentApp.canvas[cx .. ":" .. cy]
local fg = col and palette[col] or C.muted
local glyph = col and "#" or "."
text(startX + cx - 1, startY + cy - 1, glyph, fg, C.window)
hit(startX + cx - 1, startY + cy - 1, 1, 1, "paintcell", {x = cx, y = cy})
end
end
local status = currentApp.notice or tr("paint_hint") .. " | Color " .. tostring(currentApp.paintColor)
text(x + 1, y + h - 1, shorten(status, w - 2), currentApp.noticeColor or C.muted, C.panelAlt)
end
local function evaluateArithmetic(source)
source = tostring(source or "")
if source:find("[^%d%.%+%-%*/%^%(%)%s]") then return nil, tr("invalid_expression") end
local pos = 1
local function skipSpace() while source:sub(pos, pos):match("%s") do pos = pos + 1 end end
local parseExpression, parseTerm, parseUnary, parsePower, parsePrimary
parsePrimary = function()
skipSpace()
if source:sub(pos, pos) == "(" then
pos = pos + 1
local value = parseExpression()
skipSpace()
if source:sub(pos, pos) ~= ")" then error("missing )") end
pos = pos + 1
return value
end
local rest = source:sub(pos)
local token = rest:match("^(%d+%.?%d*)") or rest:match("^(%.%d+)")
if not token then error("expected number") end
pos = pos + #token
local n = tonumber(token)
if not n then error("invalid number") end
return n
end
parsePower = function()
local left = parsePrimary()
skipSpace()
if source:sub(pos, pos) == "^" then
pos = pos + 1
local right = parseUnary()
return left ^ right
end
return left
end
parseUnary = function()
skipSpace()
local ch = source:sub(pos, pos)
if ch == "+" then pos = pos + 1; return parseUnary() end
if ch == "-" then pos = pos + 1; return -parseUnary() end
return parsePower()
end
parseTerm = function()
local value = parseUnary()
while true do
skipSpace()
local op = source:sub(pos, pos)
if op ~= "*" and op ~= "/" and op ~= "%" then break end
pos = pos + 1
local rhs = parseUnary()
if (op == "/" or op == "%") and rhs == 0 then error("division by zero") end
if op == "*" then value = value * rhs elseif op == "/" then value = value / rhs else value = value % rhs end
end
return value
end
parseExpression = function()
local value = parseTerm()
while true do
skipSpace()
local op = source:sub(pos, pos)
if op ~= "+" and op ~= "-" then break end
pos = pos + 1
local rhs = parseTerm()
if op == "+" then value = value + rhs else value = value - rhs end
end
return value
end
local ok, value = pcall(function()
if trim(source) == "" then error("empty expression") end
local result = parseExpression()
skipSpace()
if pos <= #source then error("unexpected input") end
if result ~= result or result == math.huge or result == -math.huge then error("result out of range") end
return result
end)
if not ok then return nil, tostring(value) end
return value
end
local function drawCalculator()
local x, y, w, h = drawWindowFrame(tr("calculator"))
text(x + 2, y + 1, shorten("Expr: " .. (currentApp.input or ""), w - 4), C.green, C.window)
text(x + 2, y + 2, shorten(tr("result") .. ": " .. tostring(currentApp.result or ""), w - 4), C.accent, C.window)
local keys = {{"7", "8", "9", "/"}, {"4", "5", "6", "*"}, {"1", "2", "3", "-"}, {"0", ".", "C", "+"}, {"(", ")", "^", "="}}
local usable = math.max(1, w - 4)
local cellW = math.max(3, math.floor(usable / 4))
for r, row in ipairs(keys) do
local yy = y + 3 + r
if yy < y + h - 1 then
for c, key in ipairs(row) do
local xx = x + 2 + (c - 1) * cellW
if xx < x + w - 1 then
local bg = key == "=" and C.green or (key == "C" and C.red or C.panelAlt)
local fg = (key == "=" or key == "C") and C.black or C.text
text(xx, yy, shorten("[" .. key .. "]", cellW - 1), fg, bg)
hit(xx, yy, math.min(cellW - 1, x + w - 1 - xx), 1, "calckey", key)
end
end
end
end
end
local function drawCalendar()
local x, y, w, h = drawWindowFrame(tr("calendar"))
local now = os.date("*t")
local monthIndex = now.month - 1 + (currentApp.monthOffset or 0)
local year = now.year + math.floor(monthIndex / 12)
local month = (monthIndex % 12) + 1
local monthStart = os.time({year = year, month = month, day = 1, hour = 12})
local monthName = os.date("%B %Y", monthStart)
text(x + 2, y + 1, "[<]", C.green, C.window); hit(x + 2, y + 1, 3, 1, "calmonth", -1)
text(x + w - 5, y + 1, "[>]", C.green, C.window); hit(x + w - 5, y + 1, 3, 1, "calmonth", 1)
text(x + 7, y + 1, shorten(monthName, math.max(1, w - 15)), C.accent, C.window)
local days = {"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"}
local colW = math.max(1, math.floor((w - 4) / 7))
for i, day in ipairs(days) do text(x + 2 + (i - 1) * colW, y + 3, day, C.green, C.window) end
local firstWeekday = tonumber(os.date("%w", monthStart)) or 0
local lastDay = tonumber(os.date("%d", os.time({year = year, month = month + 1, day = 0, hour = 12}))) or 30
for day = 1, lastDay do
local slot = firstWeekday + day - 1
local col, row = slot % 7, math.floor(slot / 7)
local yy = y + 4 + row
if yy < y + h - 1 then
local xx = x + 2 + col * colW
local isToday = year == now.year and month == now.month and day == now.day and (currentApp.monthOffset or 0) == 0
text(xx, yy, string.format("%2d", day), isToday and C.black or C.text, isToday and C.green or C.window)
end
end
text(x + 2, y + h - 1, os.date("%Y-%m-%d %H:%M:%S"), C.muted, C.panelAlt)
end
local function drawApp()
if not currentApp then return end
if currentApp.name == "terminal" then drawTerminal()
elseif currentApp.name == "launcher" then drawLauncher()
elseif currentApp.name == "notepad" then drawNotepad()
elseif currentApp.name == "paint" then drawPaint()
elseif currentApp.name == "calculator" then drawCalculator()
elseif currentApp.name == "calendar" then drawCalendar()
elseif currentApp.name == "files" then drawFiles()
elseif currentApp.name == "fileview" then drawFileView()
elseif currentApp.name == "system" then drawSystem()
elseif currentApp.name == "settings" then drawSettings()
elseif currentApp.name == "account" then drawAccount()
elseif currentApp.name == "users" then drawUsers()
elseif currentApp.name == "about" then drawAbout()
elseif currentApp.name == "trash" then drawTrash()
elseif currentApp.name == "sandbox" then drawSandbox()
end
end
desktopState.drawContextMenu = function()
if not desktopState.contextMenu or not desktopState.contextMenu.item then return end
local sw, sh = resolution()
local ru = prefs.language == "ru"
local labels = {
open = ru and "Открыть" or "Open",
remove = ru and "Убрать с рабочего стола" or "Remove from desktop",
cancel = ru and "Отмена" or "Cancel"
}
local menuW = math.min(24, math.max(12, sw - 2))
local menuH = 4
local mx = math.max(1, math.min(sw - menuW + 1, desktopState.contextMenu.x or 2))
local my = math.max(1, math.min(sh - menuH, desktopState.contextMenu.y or 2))
fillRect(mx, my, menuW, menuH, C.panelAlt)
fillRect(mx, my, menuW, 1, C.title)
text(mx + 1, my, shorten(desktopState.contextMenu.item.label or "Lua program", menuW - 2), C.accent, C.title)
hit(mx, my, menuW, 1, "desktopctxclose")
local choices = {"open", "remove", "cancel"}
for i, key in ipairs(choices) do
text(mx + 1, my + i, shorten(labels[key], menuW - 2), key == "remove" and C.red or C.text, C.panelAlt)
hit(mx, my + i, menuW, 1, "desktopctxaction", key)
end
end
desktopState.openProgram = function(item)
if not item or not item.path or not filesystem.exists(item.path) or filesystem.isDirectory(item.path) then
invalidateDesktopPrograms()
desktopState.notice = "Desktop program is missing."
return
end
desktopState.contextMenu = nil
desktopState.notice = nil
app("terminal")
if currentApp and currentApp.name == "terminal" then
report("Launching desktop program: " .. item.path, C.green)
runLuaProgram(item.path, {})
-- Lua programs may have created/removed desktop files outside the file manager.
invalidateDesktopPrograms()
end
end
desktopState.removeProgram = function(item)
if not item or not item.entryPath or not filesystem.exists(item.entryPath) then
desktopState.notice = "Desktop item no longer exists."
return false
end
local ok, err
if tostring(item.entryName):lower():match("%.mlnk$") then
-- Remove only the shortcut; its target program remains untouched.
ok, err = filesystem.remove(item.entryPath)
else
-- A loose .lua file on the desktop is moved to Trash rather than destroyed.
local trash = "/home/MineLinux/Trash"
pcall(filesystem.makeDirectory, trash)
local base = item.entryName or (item.entryPath:match("([^/]+)$") or "program.lua")
local target = filesystem.canonical(trash .. "/" .. base)
local n = 2
while filesystem.exists(target) do
local stem, ext = base:match("^(.*)(%.[^%.]+)$")
target = filesystem.canonical(trash .. "/" .. (stem or base) .. " (" .. n .. ")" .. (ext or ""))
n = n + 1
if n > 100 then desktopState.notice = "Trash contains too many files with this name."; return false end
end
ok, err = filesystem.rename(item.entryPath, target)
end
if ok then
invalidateDesktopPrograms()
desktopState.layout[item.entryName] = nil
desktopState.saveLayout()
desktopState.selectedEntry = nil
desktopState.notice = prefs.language == "ru" and "Убрано с рабочего стола" or "Removed from desktop"
else
desktopState.notice = "Could not remove item: " .. tostring(err)
return false
end
return true
end
local function drawScene()
resetHits()
if not gpu then return end
if loginLocked then drawLogin(); return end
drawDesktopBase()
if currentApp then drawApp() end
if menuOpen then drawMenu() end
if shutdownConfirm then drawShutdownConfirm() end
if desktopState.contextMenu then desktopState.drawContextMenu() end
end
local function redraw()
if not gpu then return end
if backBuffer then pcall(gpu.setActiveBuffer, backBuffer) end
drawScene()
if backBuffer then
local ok = pcall(function()
-- Switch to the visible screen, copy the finished frame, then return to
-- the drawing buffer for the next input event.
gpu.setActiveBuffer(0)
local sw, sh = gpu.getResolution()
gpu.bitblt(0, 1, 1, sw, sh, backBuffer, 1, 1)
gpu.setActiveBuffer(backBuffer)
end)
if not ok then
-- Older or constrained GPU implementations may not support buffers.
-- Fall back gracefully rather than leaving the user with a blank screen.
pcall(gpu.setActiveBuffer, 0)
pcall(gpu.freeBuffer, backBuffer)
backBuffer = nil
backBufferWidth, backBufferHeight = nil, nil
bufferAttempted = true
drawScene()
end
end
end
local function report(message, color)
pushLog(message, color)
end
local function help()
local lines = {
"MineLinux commands:",
"help, clear, exit, desktop, about, uname, whoami, pwd",
"ls [path], cd [path], cat <file>, write <file>",
"mkdir <dir>, touch <file>, cp <src> <dst>, mv <src> <dst>",
"rm <path>, mem, uptime, date, components, history",
"lua <file.lua> [args...], run <file.lua> [args...]",
"install-autostart, disable-autostart",
"apps, notepad, paint, calculator, calendar",
"Applications: notepad, paint, calculator and calendar from the app center.",
"Desktop .lua shortcuts: right-click a Lua file in Files or drag it onto the desktop.",
"testvm <program.lua>: run an isolated test with its own persistent virtual filesystem.",
"vmfs ls|cat|mkdir|write|rm: manage the sandbox filesystem.",
"Host clipboard paste: middle mouse button or Shift+Insert.",
"Terminal: Up/Down browse previous commands; Tab completes commands and paths.",
"Desktop: drag Lua icons to move them; right-click an icon to remove it.",
"user, lock, reboot, poweroff",
"write mode: type text, .end to save, .cancel to discard",
"Tip: quote paths that contain spaces."
}
for _, line in ipairs(lines) do report(line, C.muted) end
end
local function readFile(path)
local f, err = io.open(path, "r")
if not f then return nil, err end
local contents = f:read("*a") or ""
f:close()
return contents
end
-- Execute a Lua script from the MineLinux terminal and route ordinary text output
-- into the terminal scrollback. Programs that need their own interactive event loop
-- or full-screen UI should be launched from the native OpenOS shell (`openos`).
local function runLuaProgram(pathArg, scriptArgs)
if not pathArg or pathArg == "" then
report("Usage: lua <file.lua> [args...]", C.yellow)
report("For the interactive Lua console or full-screen tools, type openos first.", C.muted)
return
end
local path = resolve(pathArg)
if not filesystem.exists(path) then
report("lua: file not found: " .. path, C.red)
return
end
if filesystem.isDirectory(path) then
report("lua: expected a file, got a directory: " .. path, C.red)
return
end
local chunk, loadErr = loadfile(path)
if not chunk then
report("lua: " .. tostring(loadErr), C.red)
return
end
scriptArgs = scriptArgs or {}
local scriptArg = {[0] = path}
for i, value in ipairs(scriptArgs) do scriptArg[i] = value end
local shellLib
pcall(function() shellLib = require("shell") end)
local oldPwd
if shellLib then
pcall(function() oldPwd = shellLib.getWorkingDirectory() end)
pcall(function() shellLib.setWorkingDirectory(currentApp.cwd or cwd or "/home") end)
end
local oldPrint, oldIoWrite = print, io.write
local oldStdout, oldStderr = io.stdout, io.stderr
local oldTermWrite, oldTermClear = term and term.write, term and term.clear
local oldArg = rawget(_G, "arg")
local outputBuffer, outputLines = "", 0
local function capture(value, color)
local textValue = tostring(value or "")
textValue = textValue:gsub("\27%[[%d;?]*[%a]", ""):gsub("\r", "")
local startAt = 1
while true do
local newlineAt = textValue:find("\n", startAt, true)
if not newlineAt then
outputBuffer = outputBuffer .. textValue:sub(startAt)
break
end
outputBuffer = outputBuffer .. textValue:sub(startAt, newlineAt - 1)
report(outputBuffer, color or C.text)
outputBuffer = ""
outputLines = outputLines + 1
startAt = newlineAt + 1
end
end
local function collectValues(...)
local values = {}
for i = 1, select("#", ...) do values[#values + 1] = tostring(select(i, ...)) end
return table.concat(values)
end
local fakeStdout, fakeStderr
fakeStdout = {
write = function(_, ...) capture(collectValues(...), C.text); return fakeStdout end,
flush = function() return true end,
close = function() return true end
}
fakeStderr = {
write = function(_, ...) capture(collectValues(...), C.red); return fakeStderr end,
flush = function() return true end,
close = function() return true end
}
local unpackValues = (table and table.unpack) or unpack
local runOk, runErr = xpcall(function()
print = function(...)
local values = {}
for i = 1, select("#", ...) do values[#values + 1] = tostring(select(i, ...)) end
capture(table.concat(values, "\t") .. "\n", C.text)
end
io.write = function(...) capture(collectValues(...), C.text); return true end
io.stdout, io.stderr = fakeStdout, fakeStderr
rawset(_G, "arg", scriptArg)
if term then
term.write = function(value) capture(value, C.text); return true end
term.clear = function() currentApp.log = {}; outputBuffer = ""; outputLines = 0; return true end
end
if #scriptArgs > 0 and type(unpackValues) == "function" then
chunk(unpackValues(scriptArgs))
else
chunk()
end
end, function(err)
return tostring(err) .. "\n" .. (debug and debug.traceback and debug.traceback() or "")
end)
-- Restore every redirected global even when the script raises an error.
print, io.write = oldPrint, oldIoWrite
io.stdout, io.stderr = oldStdout, oldStderr
if term then term.write, term.clear = oldTermWrite, oldTermClear end
rawset(_G, "arg", oldArg)
if shellLib then
local newPwd
pcall(function() newPwd = shellLib.getWorkingDirectory() end)
if newPwd and filesystem.exists(newPwd) and filesystem.isDirectory(newPwd) then
currentApp.cwd, cwd = filesystem.canonical(newPwd), filesystem.canonical(newPwd)
end
if oldPwd then pcall(function() shellLib.setWorkingDirectory(oldPwd) end) end
end
if outputBuffer ~= "" then report(outputBuffer, C.text) end
if not runOk then
report("Lua program error: " .. tostring(runErr), C.red)
elseif outputLines == 0 and outputBuffer == "" then
report("Program finished: " .. path, C.green)
end
end
-- Run a Lua program with a deliberately restricted environment and its own
-- persistent virtual filesystem. The VM cannot require OpenComputers components,
-- access the host OS/filesystem, or use shell APIs.
local function runLuaSandbox(pathArg, scriptArgs)
if not pathArg or pathArg == "" then
report("Usage: testvm <program.lua> [args...]", C.yellow)
return
end
local hostPath = resolve(pathArg)
if not filesystem.exists(hostPath) or filesystem.isDirectory(hostPath) then
report(tr("vm_not_found") .. ": " .. hostPath, C.red)
return
end
local source, readErr = readFile(hostPath)
if source == nil then report("testvm: " .. tostring(readErr), C.red); return end
local outputBuffer = ""
local function vmPrint(...)
local parts = {}
for i = 1, select("#", ...) do parts[#parts + 1] = tostring(select(i, ...)) end
local value = table.concat(parts, "\t")
for line in (value .. "\n"):gmatch("(.-)\n") do report("[VM] " .. line, C.text) end
end
local function shallowCopy(sourceTable)
local target = {}
for k, v in pairs(sourceTable or {}) do target[k] = v end
return target
end
local safeMath, safeString, safeTable = shallowCopy(math), shallowCopy(string), shallowCopy(table)
local vmIO = {
open = vmOpen,
write = vmPrint,
lines = function(path)
local f, err = vmOpen(path, "r")
if not f then return nil, err end
return function() local line = f:read("*l"); if line == nil then f:close() end; return line end
end
}
local env = {
assert = assert, error = error, ipairs = ipairs, next = next, pairs = pairs,
pcall = pcall, select = select, tonumber = tonumber, tostring = tostring,
type = type, xpcall = xpcall, unpack = unpack or (table and table.unpack),
math = safeMath, string = safeString, table = safeTable,
print = vmPrint, io = vmIO, vfs = vmfs, fs = vmfs,
_VERSION = _VERSION
}
env.arg = {[0] = hostPath}
scriptArgs = scriptArgs or {}
for i, value in ipairs(scriptArgs) do env.arg[i] = value end
local chunk, loadErr
if type(load) == "function" then
local ok, result, err = pcall(load, source, "@VM:" .. vmCanonical(hostPath:match("([^/]+)$") or "program.lua"), "t", env)
if ok and type(result) == "function" then chunk = result; loadErr = nil
else loadErr = ok and err or result end
end
if not chunk and type(loadstring) == "function" then
local ok, result, err = pcall(loadstring, source, "@VM:" .. (hostPath:match("([^/]+)$") or "program.lua"))
if ok and type(result) == "function" then
chunk, loadErr = result, nil
if type(setfenv) == "function" then pcall(setfenv, chunk, env)
else chunk, loadErr = nil, "this Lua runtime cannot assign a sandbox environment" end
else loadErr = ok and err or result end
end
if not chunk then report("testvm: compile error: " .. tostring(loadErr), C.red); return end
report("Starting sandbox: " .. hostPath, C.green)
local oldHook, oldMask, oldCount
local hookInstalled = false
if debug and type(debug.gethook) == "function" and type(debug.sethook) == "function" then
pcall(function() oldHook, oldMask, oldCount = debug.gethook() end)
local ticks = 0
hookInstalled = pcall(debug.sethook, function()
ticks = ticks + 1
if ticks > 150 then error("VM instruction budget exceeded", 0) end
end, "", 10000)
end
local ok, err = xpcall(function()
local args = {}
for i, value in ipairs(scriptArgs) do args[i] = value end
if type(unpack) == "function" then chunk(unpack(args))
elseif table and table.unpack then chunk(table.unpack(args))
else chunk() end
end, function(e) return tostring(e) end)
if hookInstalled then
if oldHook then pcall(debug.sethook, oldHook, oldMask, oldCount) else pcall(debug.sethook) end
end
if ok then report(tr("vm_complete"), C.green)
else report("[VM ERROR] " .. tostring(err), C.red) end
end
local function vmFsCommand(args)
local sub = args[2] or "ls"
local path = args[3] or "/"
if sub == "ls" then
local entries, err = vmList(path)
if not entries then report("vmfs: " .. tostring(err), C.red)
elseif #entries == 0 then report("(empty virtual directory)", C.muted)
else for _, entry in ipairs(entries) do report(entry, entry:sub(-1) == "/" and C.yellow or C.text) end end
elseif sub == "cat" then
local data, err = vmRead(path)
if data == nil then report("vmfs: " .. tostring(err), C.red)
else for line in (data .. "\n"):gmatch("(.-)\n") do report(line, C.text) end end
elseif sub == "mkdir" then
local ok, err = vmMkdir(path); report(ok and "Virtual directory created." or ("vmfs: " .. tostring(err)), ok and C.green or C.red)
elseif sub == "write" then
local filePath = args[3]
if not filePath then report("Usage: vmfs write /file.txt text...", C.yellow); return end
local pieces = {}; for i = 4, #args do pieces[#pieces + 1] = args[i] end
local ok, err = vmWrite(filePath, table.concat(pieces, " "))
report(ok and "Virtual file saved." or ("vmfs: " .. tostring(err)), ok and C.green or C.red)
elseif sub == "rm" then
if vmCanonical(path) == "/" then report("vmfs: refusing to remove virtual root", C.red)
elseif not vmExists(path) then report("vmfs: no such virtual path", C.red)
else currentApp.pending = {kind = "vmrm", path = vmCanonical(path)}; report("Type YES to remove virtual path, anything else cancels.", C.yellow) end
else report("Usage: vmfs ls|cat|mkdir|write|rm [path]", C.yellow) end
end
-- Run regular OpenOS shell commands from the MineLinux terminal.
-- Text output is routed to the terminal scrollback. Full-screen interactive tools
-- (for example, edit and the Lua REPL) should be run after switching to the native
-- OpenOS shell with the `openos` command.
local function runOpenOSCommand(commandLine)
local commandName, commandArgs = tostring(commandLine or ""):match("^%s*([^%s]+)%s*(.-)%s*$")
if not commandName or commandName == "" then return end
commandName = commandName:match("([^/]+)$") or commandName
if commandName == "edit" or commandName == "top" or commandName == "less" or commandName == "more" or commandName == "install" or commandName == "lua" and commandArgs == "" then
report("This OpenOS command needs an interactive full-screen console.", C.yellow)
report("Type 'openos' to return to the native OpenOS shell, then run it there.", C.muted)
return
end
local shellLibOk, shellLib = pcall(require, "shell")
if not shellLibOk or type(shellLib) ~= "table" or type(shellLib.execute) ~= "function" then
report("OpenOS shell API is unavailable: " .. tostring(shellLib), C.red)
return
end
local oldPwd
pcall(function() oldPwd = shellLib.getWorkingDirectory() end)
pcall(function() shellLib.setWorkingDirectory(currentApp.cwd or cwd or "/home") end)
local oldPrint = print
local oldIoWrite = io.write
local oldStdout, oldStderr = io.stdout, io.stderr
local oldTermWrite = term and term.write
local oldTermClear = term and term.clear
local outputBuffer = ""
local outputCount = 0
local function capture(value, color)
local textValue = tostring(value or "")
-- Remove ANSI color/cursor sequences that do not make sense in a scrollback log.
textValue = textValue:gsub("\27%[[%d;?]*[%a]", ""):gsub("\r", "")
local startAt = 1
while true do
local newlineAt = textValue:find("\n", startAt, true)
if not newlineAt then
outputBuffer = outputBuffer .. textValue:sub(startAt)
break
end
outputBuffer = outputBuffer .. textValue:sub(startAt, newlineAt - 1)
report(outputBuffer, color or C.text)
outputBuffer = ""
outputCount = outputCount + 1
startAt = newlineAt + 1
end
end
local function writeToCapture(color, ...)
for i = 1, select("#", ...) do capture(select(i, ...), color) end
return true
end
local fakeStdout = {
write = function(_, ...) writeToCapture(C.text, ...); return true end,
flush = function() return true end,
close = function() return true end
}
local fakeStderr = {
write = function(_, ...) writeToCapture(C.red, ...); return true end,
flush = function() return true end,
close = function() return true end
}
local resultOk, result, reason
local runOk, runErr = xpcall(function()
print = function(...)
local values = {}
for i = 1, select("#", ...) do values[#values + 1] = tostring(select(i, ...)) end
capture(table.concat(values, "\t") .. "\n", C.text)
end
io.write = function(...) return writeToCapture(C.text, ...) end
io.stdout, io.stderr = fakeStdout, fakeStderr
if term then
term.write = function(value) capture(value, C.text); return true end
term.clear = function() currentApp.log = {}; outputBuffer = ""; return true end
end
resultOk, result, reason = shellLib.execute(commandLine)
end, function(err)
return tostring(err) .. "\n" .. (debug and debug.traceback and debug.traceback() or "")
end)
-- Always restore the OpenOS globals, even if a utility crashes.
print = oldPrint
io.write = oldIoWrite
io.stdout, io.stderr = oldStdout, oldStderr
if term then
term.write = oldTermWrite
term.clear = oldTermClear
end
local newPwd
pcall(function() newPwd = shellLib.getWorkingDirectory() end)
if newPwd and filesystem.exists(newPwd) and filesystem.isDirectory(newPwd) then
currentApp.cwd, cwd = filesystem.canonical(newPwd), filesystem.canonical(newPwd)
end
if oldPwd then pcall(function() shellLib.setWorkingDirectory(oldPwd) end) end
if outputBuffer ~= "" then report(outputBuffer, C.text) end
if not runOk then
report("OpenOS command wrapper error: " .. tostring(runErr), C.red)
elseif resultOk == false then
report(tostring(result or reason or "OpenOS command failed"), C.red)
elseif type(result) == "number" and result ~= 0 then
report("OpenOS command exit code: " .. tostring(result), C.yellow)
elseif outputCount == 0 and outputBuffer == "" and result == nil then
-- Some commands succeed silently; no extra line is necessary.
end
end
local function runCommand(line)
local a = parseArgs(line)
local c = a[1]
if not c then return end
history[#history + 1] = line
if currentApp and currentApp.name == "terminal" then
currentApp.historyIndex = #history + 1
currentApp.draftInput = ""
end
if currentApp.writeMode then
if line == ".cancel" then
currentApp.writeMode = nil
report("Write cancelled.", C.yellow)
elseif line == ".end" then
local f, err = io.open(currentApp.writeMode, "w")
if not f then report("write: " .. tostring(err), C.red)
else
f:write(table.concat(currentApp.writeLines, "\n"))
if #currentApp.writeLines > 0 then f:write("\n") end
f:close()
report("Saved: " .. currentApp.writeMode, C.green)
end
currentApp.writeMode, currentApp.writeLines = nil, nil
else
currentApp.writeLines[#currentApp.writeLines + 1] = line
end
return
end
if currentApp.pending then
local pending = currentApp.pending
currentApp.pending = nil
if line ~= "YES" then report("Cancelled.", C.yellow); return end
if pending.kind == "rm" then
local ok, err = filesystem.remove(pending.path)
report(ok and ("Deleted: " .. pending.path) or ("rm: " .. tostring(err)), ok and C.green or C.red)
elseif pending.kind == "reboot" then computer.shutdown(true)
elseif pending.kind == "poweroff" then computer.shutdown(false)
elseif pending.kind == "vmrm" then
local ok, err = vmRemove(pending.path)
report(ok and ("Removed virtual path: " .. pending.path) or ("vmfs: " .. tostring(err)), ok and C.green or C.red)
end
return
end
if c == "help" then help()
elseif c == "apps" or c == "applications" then app("launcher")
elseif c == "vm" or c == "sandbox" then app("sandbox")
elseif c == "testvm" or c == "vmrun" then
local scriptArgs = {}; for i = 3, #a do scriptArgs[#scriptArgs + 1] = a[i] end
runLuaSandbox(a[2], scriptArgs)
elseif c == "vmfs" then vmFsCommand(a)
elseif c == "bios" then
report("BIOS profile: " .. tostring(biosStatus), C.green)
report("EEPROM label: " .. tostring(biosLabelStatus), C.muted)
if biosWarning then report(tostring(biosWarning), C.yellow) end
elseif c == "notepad" then app("notepad")
elseif c == "paint" then app("paint")
elseif c == "calculator" or c == "calc" then app("calculator")
elseif c == "calendar" then app("calendar")
elseif c == "clear" then currentApp.log = {}
elseif c == "openos" or c == "os-shell" then
report("Returning to the native OpenOS shell...", C.green)
leavingToOpenOS = true
running = false
currentApp = nil
menuOpen = false
elseif c == "os" then
local nativeCommand = line:match("^%s*os%s+(.+)$")
if not nativeCommand then
report("Usage: os <OpenOS command>", C.yellow)
report("Example: os pastebin get ID /home/file.lua", C.muted)
else
runOpenOSCommand(nativeCommand)
end
elseif c == "exit" or c == "desktop" or c == "gui" then currentApp = nil
elseif c == "about" then
report("MineLinux " .. VERSION .. " - Cinnamon-inspired desktop", C.green)
report("A Linux-like userland running on OpenOS, not a Linux kernel.", C.muted)
elseif c == "uname" then report("MineLinux mc1.7.10-oc " .. VERSION, C.text)
elseif c == "whoami" then report(prefs.username, C.text)
elseif c == "user" then report("User: " .. prefs.username .. " Avatar: " .. prefs.avatar, C.text)
elseif c == "lock" or c == "logout" then
selectedLoginIndex = activeUserIndex
loginInput, loginMessage = "", ""
authMode = "login"; loginLocked = true; currentApp = nil; menuOpen = false
elseif c == "pwd" then report(currentApp.cwd, C.text)
elseif c == "ls" then
local path = resolve(a[2] or currentApp.cwd)
if not filesystem.exists(path) then report("ls: no such path: " .. path, C.red)
elseif not filesystem.isDirectory(path) then report(path, C.text)
else
local iter, err = filesystem.list(path)
if not iter then report("ls: " .. tostring(err), C.red)
else
local entries = {}
for e in iter do entries[#entries + 1] = e end
table.sort(entries)
if #entries == 0 then report("(empty directory)", C.muted) end
for _, e in ipairs(entries) do report(e, e:sub(-1) == "/" and C.yellow or C.text) end
end
end
elseif c == "cd" then
local path = resolve(a[2] or "/home")
if filesystem.exists(path) and filesystem.isDirectory(path) then
currentApp.cwd, cwd = path, path
report("Directory: " .. path, C.green)
else report("cd: no such directory: " .. path, C.red) end
elseif c == "cat" then
if not a[2] then report("usage: cat <file>", C.yellow); return end
local path = resolve(a[2])
local contents, err = readFile(path)
if not contents then report("cat: " .. tostring(err), C.red)
else
local count = 0
for l in (contents .. "\n"):gmatch("(.-)\n") do
report(l, C.text); count = count + 1
if count >= 80 then report("... output truncated", C.muted); break end
end
end
elseif c == "write" then
if not a[2] then report("usage: write <file>", C.yellow)
else
local path = resolve(a[2])
if filesystem.isDirectory(path) then report("write: path is a directory", C.red)
else currentApp.writeMode, currentApp.writeLines = path, {}; report("Writing " .. path .. ". Type .end to save or .cancel to cancel.", C.green) end
end
elseif c == "mkdir" then
if not a[2] then report("usage: mkdir <dir>", C.yellow)
else
local path = resolve(a[2]); local ok, err = filesystem.makeDirectory(path)
report(ok and ("Created: " .. path) or ("mkdir: " .. tostring(err)), ok and C.green or C.red)
end
elseif c == "touch" then
if not a[2] then report("usage: touch <file>", C.yellow)
else
local path = resolve(a[2]); local f, err = io.open(path, "a")
if f then f:close(); report("Created: " .. path, C.green) else report("touch: " .. tostring(err), C.red) end
end
elseif c == "cp" then
if not a[2] or not a[3] then report("usage: cp <src> <dst>", C.yellow)
else local ok, err = filesystem.copy(resolve(a[2]), resolve(a[3])); report(ok and "Copied." or ("cp: " .. tostring(err)), ok and C.green or C.red) end
elseif c == "mv" then
if not a[2] or not a[3] then report("usage: mv <src> <dst>", C.yellow)
else local ok, err = filesystem.rename(resolve(a[2]), resolve(a[3])); report(ok and "Moved." or ("mv: " .. tostring(err)), ok and C.green or C.red) end
elseif c == "rm" then
if not a[2] then report("usage: rm <path>", C.yellow)
else
local path = resolve(a[2])
if path == "/" then report("rm: refusing to delete root", C.red)
elseif not filesystem.exists(path) then report("rm: path not found", C.red)
else currentApp.pending = {kind = "rm", path = path}; report("Delete " .. path .. "? Type YES to confirm.", C.yellow) end
end
elseif c == "mem" then
local total, free = computer.totalMemory(), computer.freeMemory()
report(string.format("Memory used: %d / %d bytes", total - free, total), C.text)
report(string.format("Memory free: %d bytes", free), C.text)
elseif c == "uptime" then report(string.format("Uptime: %.1f seconds", computer.uptime()), C.text)
elseif c == "date" then report(os.date("%Y-%m-%d %H:%M:%S"), C.text)
elseif c == "components" then
for address, kind in component.list() do report(string.format("%-12s %s", kind, address:sub(1, 12)), C.text) end
elseif c == "history" then for i, cmd in ipairs(history) do report(i .. " " .. cmd, C.muted) end
elseif c == "install-autostart" then
local path = "/home/.shrc"
local contents = readFile(path) or ""
local lineToAdd = "lua /home/minelinux.lua"
if contents:find(lineToAdd, 1, true) then report("Autostart is already configured.", C.yellow)
else
local f, err = io.open(path, "a")
if not f then report("Autostart: " .. tostring(err), C.red)
else
if contents ~= "" and contents:sub(-1) ~= "\n" then f:write("\n") end
f:write(lineToAdd .. "\n"); f:close()
report("MineLinux added to /home/.shrc", C.green)
end
end
elseif c == "disable-autostart" then
local path = "/home/.shrc"
local contents = readFile(path) or ""
local lines = {}
for l in (contents .. (contents:sub(-1) == "\n" and "" or "\n")):gmatch("(.-)\n") do
if l ~= "lua /home/minelinux.lua" then lines[#lines + 1] = l end
end
local f, err = io.open(path, "w")
if not f then report("Autostart: " .. tostring(err), C.red)
else f:write(table.concat(lines, "\n")); f:close(); report("Autostart disabled.", C.green) end
elseif c == "lua" or c == "run" then
if not a[2] then
report("Usage: " .. c .. " <file.lua> [args...]", C.yellow)
report("Example: lua /home/hello.lua Bob", C.muted)
report("For interactive Lua, type openos and then run lua without a file.", C.muted)
else
local scriptArgs = {}
for i = 3, #a do scriptArgs[#scriptArgs + 1] = a[i] end
runLuaProgram(a[2], scriptArgs)
end
elseif c == "reboot" or c == "poweroff" then
currentApp.pending = {kind = c}; report("Type YES to confirm " .. c .. ".", C.yellow)
else
-- Preserve MineLinux built-ins, but let OpenOS provide all other commands.
runOpenOSCommand(line)
end
end
local function openFileEntry(path)
if filesystem.isDirectory(path) then
currentApp.path = path
currentApp.selected = 1
currentApp.selectedPath = nil
currentApp.contextMenu = nil
currentApp.notice = nil
currentApp.error = nil
refreshEntries()
else
local ext = tostring(path):lower():match("%.([%w_%-]+)$")
if ext == "txt" or ext == "lua" or ext == "cfg" or ext == "log" or ext == "md" or ext == "sh" then
currentApp = {name = "notepad", title = tr("notepad"), path = path, contents = readTextSafe(path) or "", modified = false, pathMode = nil, pathInput = "", notice = nil, win = rememberedWindow("notepad")}
else
currentApp = {name = "fileview", title = tr("text_viewer"), path = path, win = rememberedWindow("fileview")}
end
end
end
local dragState = nil
-- Interpolate a Paint stroke from the previous canvas cell to the latest mouse
-- position. Bresenham line drawing prevents dotted strokes when drag events are
-- delayed or skip several terminal cells between updates.
desktopState.paintStrokeToScreen = function(screenX, screenY, lastX, lastY, color)
if not currentApp or currentApp.name ~= "paint" then return nil, nil end
screenX, screenY = tonumber(screenX), tonumber(screenY)
if not screenX or not screenY then return nil, nil end
local x, y, w, h = windowRect(currentApp)
local startX, startY = x + 2, y + 4
local width, height = math.max(1, w - 4), math.max(1, h - 6)
if screenX < startX or screenX >= startX + width or screenY < startY or screenY >= startY + height then
return nil, nil
end
local cellX, cellY = screenX - startX + 1, screenY - startY + 1
local canvas = currentApp.canvas
if type(canvas) ~= "table" then canvas = {}; currentApp.canvas = canvas end
local dx, sx = math.abs(cellX - lastX), (lastX < cellX) and 1 or -1
local dy, sy = -math.abs(cellY - lastY), (lastY < cellY) and 1 or -1
local err = dx + dy
while true do
local px = math.max(1, math.min(width, lastX))
local py = math.max(1, math.min(height, lastY))
canvas[px .. ":" .. py] = math.max(1, math.min(8, tonumber(color) or currentApp.paintColor or 2))
if lastX == cellX and lastY == cellY then break end
local e2 = 2 * err
if e2 >= dy then err = err + dy; lastX = lastX + sx end
if e2 <= dx then err = err + dx; lastY = lastY + sy end
end
return cellX, cellY
end
local function copyPathRecursive(source, destination)
if filesystem.exists(destination) then return false, "target already exists" end
if filesystem.isDirectory(source) then
local created, createErr = filesystem.makeDirectory(destination)
if not created then return false, createErr or "could not create target folder" end
local iter, listErr = filesystem.list(source)
if not iter then
pcall(filesystem.remove, destination)
return false, listErr or "could not read source folder"
end
for entry in iter do
local name = entry:gsub("/$", "")
local from = filesystem.canonical((source == "/" and "" or source) .. "/" .. name)
local to = filesystem.canonical((destination == "/" and "" or destination) .. "/" .. name)
local ok, err = copyPathRecursive(from, to)
if not ok then
pcall(filesystem.remove, destination)
return false, "copy failed at " .. name .. ": " .. tostring(err or "unknown error")
end
end
return true
end
return filesystem.copy(source, destination)
end
local function startFilePrompt(kind)
local selected = selectedFilePath()
if kind == "shortcut" then
if not selected then fileNotice("Select a .lua program first.", C.yellow); return end
local ok, message = createDesktopShortcut(selected)
fileNotice(message, ok and C.green or C.red)
currentApp.contextMenu = nil
return
end
if (kind == "rename" or kind == "delete" or kind == "copy" or kind == "cut" or kind == "open" or kind == "properties") and not selected then
fileNotice("Select a file or folder first.", C.yellow)
return
end
if kind == "newfile" or kind == "newdir" then
currentApp.inputMode = {kind = kind, buffer = ""}
elseif kind == "rename" then
currentApp.inputMode = {kind = kind, buffer = pathBase(selected), source = selected}
elseif kind == "delete" then
currentApp.inputMode = {kind = kind, buffer = "", source = selected}
elseif kind == "copy" or kind == "cut" then
fileClipboard = {path = selected, mode = kind}
fileNotice((kind == "copy" and "Copied to clipboard: " or "Cut to clipboard: ") .. pathBase(selected), C.green)
elseif kind == "open" then
openFileEntry(selected)
elseif kind == "properties" then
local size = "unknown"
local ok, value = pcall(filesystem.size, selected)
if ok and value then size = tostring(value) .. " bytes" end
fileNotice((filesystem.isDirectory(selected) and "Folder" or "File") .. ": " .. pathBase(selected) .. " | " .. size, C.accent)
elseif kind == "refresh" then
refreshEntries(); fileNotice("Folder refreshed.", C.green)
elseif kind == "paste" then
if not fileClipboard or not filesystem.exists(fileClipboard.path) then
fileNotice("Clipboard is empty or source no longer exists.", C.yellow)
return
end
local source = filesystem.canonical(fileClipboard.path)
local dest = filesystem.canonical((currentApp.path == "/" and "" or currentApp.path) .. "/" .. pathBase(source))
if source == dest then
fileNotice("Source and destination are the same.", C.yellow)
return
end
if filesystem.isDirectory(source) and dest:sub(1, #source + 1) == source .. "/" then
fileNotice("Cannot paste a folder inside itself.", C.red)
return
end
if filesystem.exists(dest) then
fileNotice("A file/folder with that name already exists.", C.yellow)
return
end
local ok, err
if fileClipboard.mode == "cut" then ok, err = filesystem.rename(source, dest)
else ok, err = copyPathRecursive(source, dest) end
if ok then
invalidateDesktopProgramsIfPath(source)
invalidateDesktopProgramsIfPath(dest)
fileNotice((fileClipboard.mode == "cut" and "Moved: " or "Copied: ") .. pathBase(dest), C.green)
if fileClipboard.mode == "cut" then fileClipboard = nil end
refreshEntries()
else
fileNotice("Paste failed: " .. tostring(err or "operation failed"), C.red)
end
end
end
local function commitFilePrompt()
local mode = currentApp and currentApp.inputMode
if not mode then return end
local value = mode.buffer or ""
currentApp.inputMode = nil
if mode.kind == "delete" then
if value ~= "YES" then fileNotice("Delete cancelled. Type YES exactly to confirm.", C.yellow); return end
local source = mode.source
if not source or source == "/" then fileNotice("Refusing to delete root.", C.red); return end
local ok, err = filesystem.remove(source)
if ok then
invalidateDesktopProgramsIfPath(source)
currentApp.selectedPath = nil
currentApp.selected = 1
fileNotice("Deleted: " .. pathBase(source), C.green)
refreshEntries()
else fileNotice("Delete failed: " .. tostring(err or "folder may not be empty"), C.red) end
return
end
local name, err = safeEntryName(value)
if not name then fileNotice(err, C.red); return end
local dest = filesystem.canonical((currentApp.path == "/" and "" or currentApp.path) .. "/" .. name)
if mode.kind == "rename" then
local source = mode.source
if not source or not filesystem.exists(source) then fileNotice("Source no longer exists.", C.red); return end
if dest == source then fileNotice("Name unchanged.", C.muted); return end
if filesystem.exists(dest) then fileNotice("Target already exists.", C.yellow); return end
local ok, renameErr = filesystem.rename(source, dest)
if ok then
invalidateDesktopProgramsIfPath(source)
invalidateDesktopProgramsIfPath(dest)
currentApp.selectedPath = dest
fileNotice("Renamed to " .. name, C.green)
refreshEntries()
else fileNotice("Rename failed: " .. tostring(renameErr), C.red) end
elseif mode.kind == "newfile" then
if filesystem.exists(dest) then fileNotice("That name already exists.", C.yellow); return end
local f, openErr = io.open(dest, "w")
if not f then fileNotice("Create failed: " .. tostring(openErr), C.red); return end
f:close()
invalidateDesktopProgramsIfPath(dest)
currentApp.selectedPath = dest
fileNotice("Created file: " .. name, C.green)
refreshEntries()
elseif mode.kind == "newdir" then
if filesystem.exists(dest) then fileNotice("That name already exists.", C.yellow); return end
local ok, mkdirErr = filesystem.makeDirectory(dest)
if ok then
invalidateDesktopProgramsIfPath(dest)
currentApp.selectedPath = dest
fileNotice("Created folder: " .. name, C.green)
refreshEntries()
else fileNotice("Create folder failed: " .. tostring(mkdirErr), C.red) end
end
end
local function handleAction(h, px, py)
if not h then return end
local a, v = h.action, h.value
if a == "windowdrag" and currentApp and not (currentApp.win and currentApp.win.maximized) then
local x, y, w, hh = windowRect(currentApp)
dragState = {mode = "move", startX = px or x, startY = py or y, x = x, y = y, w = w, h = hh}
elseif a == "windowresize" and currentApp and not (currentApp.win and currentApp.win.maximized) then
local x, y, w, hh = windowRect(currentApp)
dragState = {mode = "resize", startX = px or x, startY = py or y, x = x, y = y, w = w, h = hh}
elseif a == "maximize" and currentApp then
windowRect(currentApp) -- ensure geometry has been initialized and clamped
currentApp.win.maximized = not (currentApp.win.maximized == true)
windowRect(currentApp) -- persist the normal geometry plus maximize state
dragState = nil
elseif a == "restoremin" then
restoreMinimized(v)
elseif a == "taskcurrent" then
if currentApp then parkCurrentApp() end
elseif a == "desktopctxclose" then
desktopState.contextMenu = nil
elseif a == "desktopctxaction" then
local item = desktopState.contextMenu and desktopState.contextMenu.item
desktopState.contextMenu = nil
if v == "open" then
desktopState.openProgram(item)
elseif v == "remove" then
desktopState.removeProgram(item)
end
elseif a == "desktopProgram" then
-- The event loop handles press/drag/release so a click can also be a drag.
elseif a == "app" then app(v)
elseif a == "launchapp" then app(v)
elseif a == "notepadfocus" and currentApp and currentApp.name == "notepad" then
-- The editor accepts keyboard input and OS clipboard paste while focused.
elseif a == "notepadaction" and currentApp and currentApp.name == "notepad" then
if v == "new" then
currentApp.path = "/home/Documents/untitled.txt"
currentApp.contents = ""
currentApp.modified = true
currentApp.pathMode = nil
currentApp.notice = nil
elseif v == "open" then
currentApp.pathMode = "open"
currentApp.pathInput = currentApp.path or "/home/Documents/untitled.txt"
currentApp.notice = nil
elseif v == "saveas" then
currentApp.pathMode = "saveas"
currentApp.pathInput = currentApp.path or "/home/Documents/untitled.txt"
currentApp.notice = nil
elseif v == "save" then
local ok, err = writeTextSafe(currentApp.path, currentApp.contents or "")
currentApp.notice = ok and tr("saved") or ("Save failed: " .. tostring(err))
currentApp.noticeColor = ok and C.green or C.red
if ok then currentApp.modified = false end
end
elseif a == "paintcolor" and currentApp and currentApp.name == "paint" then
currentApp.paintColor = math.max(1, math.min(8, tonumber(v) or 1)); currentApp.notice = nil
elseif a == "paintcell" and currentApp and currentApp.name == "paint" and type(v) == "table" then
local color = currentApp.paintColor or 2
currentApp.canvas[v.x .. ":" .. v.y] = color
dragState = {mode = "paintstroke", lastX = v.x, lastY = v.y, color = color}
currentApp.notice = nil
elseif a == "paintaction" and currentApp and currentApp.name == "paint" then
if v == "save" then savePaintFile()
elseif v == "load" then loadPaintFile()
elseif v == "clear" then currentApp.canvas = {}; currentApp.notice = nil; if dragState and dragState.mode == "paintstroke" then dragState = nil end end
elseif a == "calckey" and currentApp and currentApp.name == "calculator" then
if v == "C" then currentApp.input, currentApp.result = "", ""
elseif v == "=" then
local value, err = evaluateArithmetic(currentApp.input)
currentApp.result = value ~= nil and tostring(value) or ("Error: " .. tostring(err))
else currentApp.input = (currentApp.input or "") .. tostring(v) end
elseif a == "calmonth" and currentApp and currentApp.name == "calendar" then
currentApp.monthOffset = (currentApp.monthOffset or 0) + (tonumber(v) or 0)
elseif a == "menu" then menuOpen = not menuOpen
elseif a == "filecontextclose" and currentApp and currentApp.name == "files" then currentApp.contextMenu = nil
elseif a == "filecontext" and currentApp and currentApp.name == "files" then
currentApp.contextMenu = {x = px or 3, y = py or 4}
elseif a == "fileaction" and currentApp and currentApp.name == "files" then
currentApp.contextMenu = nil
startFilePrompt(v)
elseif a == "filesnav" and currentApp and currentApp.name == "files" then
if filesystem.isDirectory(v) then currentApp.path = filesystem.canonical(v); currentApp.selected = 1; currentApp.selectedPath = nil; currentApp.notice = nil; refreshEntries()
else fileNotice("Folder not found: " .. tostring(v), C.red) end
elseif a == "filepath" and currentApp and currentApp.name == "files" then
currentApp.contextMenu = nil
elseif a == "fileparent" then
if currentApp and currentApp.name == "files" then currentApp.path = parentPath(currentApp.path); currentApp.selected = 1; currentApp.selectedPath = nil; currentApp.notice = nil; currentApp.contextMenu = nil; refreshEntries() end
elseif a == "fileentry" and currentApp and currentApp.name == "files" then
currentApp.contextMenu = nil
currentApp.selectedPath = v
for i, entry in ipairs(currentApp.entries) do
local name = entry:gsub("/$", "")
local child = filesystem.canonical((currentApp.path == "/" and "" or currentApp.path) .. "/" .. name)
if child == v then currentApp.selected = i; break end
end
local now = computer.uptime()
if not filesystem.isDirectory(v) and tostring(v):lower():match("%.lua$") then
dragState = {mode = "desktopShortcut", source = v, startX = tonumber(px) or 1, startY = tonumber(py) or 1, moved = false}
end
if lastFileClickPath == v and now - lastFileClickTime < 0.65 then
dragState = nil
openFileEntry(v)
lastFileClickPath, lastFileClickTime = nil, 0
else
lastFileClickPath, lastFileClickTime = v, now
end
elseif a == "filerefresh" then startFilePrompt("refresh")
elseif a == "shutdown" then
shutdownConfirm = true
menuOpen = false
elseif a == "shutdownmodalblock" then
-- Modal backdrop intentionally consumes clicks outside the confirmation buttons.
elseif a == "shutdownyes" then
shutdownConfirm = false
menuOpen = false
if gpu and backBuffer then pcall(gpu.setActiveBuffer, 0) end
computer.shutdown(false)
elseif a == "shutdownno" then
shutdownConfirm = false
elseif a == "exit" then running = false; menuOpen = false
elseif a == "logout" then
selectedLoginIndex = activeUserIndex; loginInput, loginMessage = "", ""; authMode = "login"; loginLocked = true; currentApp = nil; menuOpen = false
elseif a == "users" then app("users")
elseif a == "close" then
if currentApp and currentApp.name == "fileview" then
currentApp = {name = "files", title = "Files", path = parentPath(currentApp.path), entries = {}}
refreshEntries()
else currentApp = nil end
menuOpen = false
elseif a == "minimize" then parkCurrentApp(); menuOpen = false
elseif a == "theme" then
themeIndex = themeIndex % #themes + 1; prefs.theme = themeIndex; setTheme(); savePrefs()
elseif a == "wallpaper" then
prefs.wallpaper = prefs.wallpaper % #wallpaperNames + 1; savePrefs()
elseif a == "clockformat" then
prefs.clock24 = not prefs.clock24; savePrefs()
elseif a == "clockreal" then
prefs.realLifeClock = not (prefs.realLifeClock ~= false); savePrefs()
elseif a == "showdate" then
prefs.showDate = not (prefs.showDate ~= false); savePrefs()
elseif a == "language" then
prefs.language = prefs.language == "ru" and "en" or "ru"
savePrefs()
if currentApp then currentApp.title = tr("settings_title"); currentApp.notice = tr("switched_language"); currentApp.noticeColor = C.green end
elseif a == "settingtab" and currentApp and currentApp.name == "settings" then
currentApp.settingsTab = v; currentApp.notice = nil; currentApp.noticeColor = nil
elseif a == "account" then app("account")
elseif a == "accountfield" and currentApp and currentApp.name == "account" then
currentApp.editField = v
if currentApp.form and currentApp.form[v] ~= nil then currentApp.form[v] = "" end
elseif a == "accountsave" and currentApp and currentApp.name == "account" then
local form = currentApp.form or {}
local username = trim(form.username or "")
if username == "" then
currentApp.notice, currentApp.noticeColor = "Username cannot be empty.", C.red
elseif accountNameExists(username, activeUserIndex) then
currentApp.notice, currentApp.noticeColor = tr("duplicate_user"), C.red
elseif form.pass1 ~= form.pass2 then
currentApp.notice, currentApp.noticeColor = "Passwords do not match.", C.red
else
prefs.username = sanitizeSetting(username, 24)
local avatar = trim(form.avatar or "@")
if avatar == "" then avatar = "@" end
prefs.avatar = sanitizeSetting(avatar:sub(1, 2), 2)
if form.pass1 ~= "" then prefs.passwordHash = hashPassword(form.pass1) end
prefs.lockAtStartup = prefs.passwordHash ~= ""
local accountOk, accountErr = saveActiveAccount()
local prefOk, prefErr = savePrefs()
local ok = accountOk and prefOk
local err = accountErr or prefErr
currentApp.notice = ok and "Profile saved." or ("Save failed: " .. tostring(err))
currentApp.noticeColor = ok and C.green or C.red
form.pass1, form.pass2 = "", ""
end
elseif a == "accountclearpass" and currentApp and currentApp.name == "account" then
prefs.passwordHash = ""; prefs.lockAtStartup = false
local accountOk, accountErr = saveActiveAccount()
local prefOk, prefErr = savePrefs()
local ok = accountOk and prefOk
currentApp.notice = ok and "Password removed." or ("Save failed: " .. tostring(accountErr or prefErr))
currentApp.noticeColor = ok and C.green or C.red
elseif a == "lockstartup" then
if currentApp then currentApp.notice = (prefs.passwordHash ~= "" and "Password is required at sign-in." or "Set a password to protect this account."); currentApp.noticeColor = C.muted end
elseif a == "setupfield" and authMode == "setup" and setupState then
setupState.field = tonumber(v) or 1
elseif a == "setuplang" and authMode == "setup" and setupState then
setupState.language = setupState.language == "ru" and "en" or "ru"
elseif a == "setuptheme" and authMode == "setup" and setupState then
setupState.theme = (tonumber(setupState.theme) or 1) % #themes + 1
elseif a == "setupfinish" and authMode == "setup" then
finishInitialSetup()
elseif a == "loginnext" and loginLocked and authMode == "login" then
selectedLoginIndex = selectedLoginIndex % math.max(1, #users) + 1; loginInput = ""; loginMessage = ""
elseif a == "loginpass" and loginLocked and authMode == "login" then
-- Password field is active by default; this hit target also lets touch users focus it.
elseif a == "userselect" and currentApp and currentApp.name == "users" then
currentApp.userSelected = tonumber(v) or 1
elseif a == "usernew" and currentApp and currentApp.name == "users" then
currentApp.usersMode = "new"; currentApp.userForm = {username = "", avatar = "@", pass1 = "", pass2 = ""}; currentApp.editField = "username"; currentApp.notice = nil
elseif a == "usernewfield" and currentApp and currentApp.name == "users" then
currentApp.editField = v
elseif a == "usercancel" and currentApp and currentApp.name == "users" then
currentApp.usersMode = "list"; currentApp.userForm = nil; currentApp.notice = nil
elseif a == "usersaves" and currentApp and currentApp.name == "users" then
local form = currentApp.userForm or {}
local name = sanitizeSetting(trim(form.username or ""), 24)
if name == "" then
currentApp.notice, currentApp.noticeColor = tr("setup_name") .. " cannot be empty.", C.red
elseif accountNameExists(name) then
currentApp.notice, currentApp.noticeColor = tr("duplicate_user"), C.red
elseif (form.pass1 or "") ~= (form.pass2 or "") then
currentApp.notice, currentApp.noticeColor = "Passwords do not match.", C.red
else
local avatar = sanitizeSetting(trim(form.avatar or "@"), 2); if avatar == "" then avatar = "@" end
users[#users + 1] = {username = name, avatar = avatar, passwordHash = (form.pass1 ~= "" and hashPassword(form.pass1) or "")}
local ok, err = saveUsers()
if ok then currentApp.usersMode = "list"; currentApp.userSelected = #users; currentApp.userForm = nil; currentApp.notice = "Account created."; currentApp.noticeColor = C.green
else users[#users] = nil; currentApp.notice = "Save failed: " .. tostring(err); currentApp.noticeColor = C.red end
end
elseif a == "userdelete" and currentApp and currentApp.name == "users" then
local index = tonumber(currentApp.userSelected) or 1
if #users <= 1 then currentApp.notice, currentApp.noticeColor = tr("last_account"), C.yellow
elseif index == activeUserIndex then currentApp.notice, currentApp.noticeColor = tr("cannot_delete_active"), C.yellow
elseif users[index] then currentApp.pendingDelete = index; currentApp.notice, currentApp.noticeColor = tr("delete_confirm"), C.yellow end
elseif a == "resetwins" then
appWindows = {}
if currentApp then currentApp.win = nil; currentApp.notice = tr("reset_windows") .. " ✓"; currentApp.noticeColor = C.green end
elseif a == "terminalfocus" then
-- Click into the prompt area; keyboard input is always routed to the active terminal.
end
end
desktopState.terminalCommands = {
"help", "apps", "applications", "vm", "sandbox", "testvm", "vmrun", "vmfs", "bios",
"notepad", "paint", "calculator", "calc", "calendar", "clear", "openos", "os-shell", "os",
"exit", "desktop", "gui", "about", "uname", "whoami", "pwd", "ls", "dir", "cd", "cat",
"write", "mkdir", "touch", "cp", "mv", "rm", "mem", "uptime", "date", "components", "history",
"install-autostart", "disable-autostart", "user", "lock", "reboot", "poweroff", "lua", "run", "vmfs"
}
desktopState.commonPrefix = function(values)
if #values == 0 then return "" end
local prefix = values[1]
for i = 2, #values do
local other = values[i]
while prefix ~= "" and other:sub(1, #prefix) ~= prefix do prefix = prefix:sub(1, -2) end
if prefix == "" then break end
end
return prefix
end
desktopState.completeTerminal = function()
if not currentApp or currentApp.name ~= "terminal" then return end
local input = currentApp.input or ""
local lead, partial = input:match("^(.*%s)([^%s]*)$")
if not lead then lead, partial = "", input end
if input:match("%s$") then lead, partial = input, "" end
local candidates = {}
local isCommand = lead == "" and not partial:find("/", 1, true)
if isCommand then
local seen = {}
for _, name in ipairs(desktopState.terminalCommands) do
if name:sub(1, #partial):lower() == partial:lower() and not seen[name] then
candidates[#candidates + 1] = name
seen[name] = true
end
end
else
local prefix = partial:match("^(.*[/])") or ""
local leaf = partial:match("([^/]*)$") or partial
local directory = prefix == "" and (currentApp.cwd or cwd) or resolve(prefix)
local iter = filesystem.list(directory)
if iter then
for entry in iter do
local name = entry:gsub("/$", "")
if name:sub(1, #leaf):lower() == leaf:lower() then
candidates[#candidates + 1] = prefix .. name .. (entry:sub(-1) == "/" and "/" or "")
end
end
end
end
table.sort(candidates, function(a, b) return a:lower() < b:lower() end)
if #candidates == 1 then
local suffix = candidates[1]:sub(-1) == "/" and "" or " "
currentApp.input = lead .. candidates[1] .. suffix
elseif #candidates > 1 then
local prefix = desktopState.commonPrefix(candidates)
if #prefix > #partial then
currentApp.input = lead .. prefix
else
report(table.concat(candidates, " "), C.muted)
end
end
end
local function handleKey(char, code)
-- Some OpenOS builds also surface Ctrl+C as character code 3.
if char == 3 then
if currentApp then
currentApp.notice = "Ctrl+C is disabled while MineLinux is running. Use `openos` to return normally."
currentApp.noticeColor = C.yellow
end
return
end
if loginLocked then
if authMode == "setup" and setupState then
local st = setupState
if char == 13 or code == 28 then
if (st.field or 1) < 4 then st.field = st.field + 1
else finishInitialSetup() end
elseif char == 27 or code == 1 then
st.notice = "Initial setup must be completed before using MineLinux."; st.noticeColor = C.yellow
elseif code == 60 then
st.language = st.language == "ru" and "en" or "ru"
elseif code == 61 then
st.theme = (tonumber(st.theme) or 1) % #themes + 1
elseif char == 8 or char == 127 or code == 14 then
local key = ({"username", "avatar", "pass1", "pass2"})[st.field or 1]
if key and st[key] then st[key] = st[key]:sub(1, -2) end
elseif char >= 32 and char <= 126 then
local key = ({"username", "avatar", "pass1", "pass2"})[st.field or 1]
local limit = key == "username" and 24 or (key == "avatar" and 2 or 64)
if key and #(st[key] or "") < limit then st[key] = (st[key] or "") .. string.char(char) end
end
return
end
if char == 13 or code == 28 then
local user = users[selectedLoginIndex]
if not user then loginMessage = "No account configured."; return end
if (user.passwordHash or "") == "" or hashPassword(loginInput) == user.passwordHash then
activateUser(selectedLoginIndex)
else
loginInput = ""; loginMessage = tr("wrong_password")
end
elseif code == 200 or code == 208 then
if #users > 0 then
local direction = code == 200 and -1 or 1
selectedLoginIndex = ((selectedLoginIndex - 1 + direction) % #users) + 1
end
loginInput, loginMessage = "", ""
elseif char == 27 or code == 1 then
loginInput = ""
elseif char == 8 or char == 127 or code == 14 then
loginInput = loginInput:sub(1, -2)
elseif char >= 32 and char <= 126 then
if #loginInput < 64 then loginInput = loginInput .. string.char(char) end
loginMessage = ""
end
return
end
if shutdownConfirm then
if char == 121 or char == 89 or char == 13 or code == 28 then
shutdownConfirm = false
menuOpen = false
if gpu and backBuffer then pcall(gpu.setActiveBuffer, 0) end
computer.shutdown(false)
elseif char == 110 or char == 78 or char == 27 or code == 1 then
shutdownConfirm = false
end
return
end
if desktopState.contextMenu and (char == 27 or code == 1) then
desktopState.contextMenu = nil
return
end
if menuOpen then
if char == 27 or code == 1 then menuOpen = false
elseif (char >= 49 and char <= 57) or char == 48 then
local n = char == 48 and 10 or (char - 48)
local apps = {"terminal", "files", "launcher", "sandbox", "system", "settings", "account", "users", "logout", "shutdown"}
if apps[n] == "shutdown" then handleAction({action = "shutdown"})
elseif apps[n] == "logout" then selectedLoginIndex = activeUserIndex; authMode = "login"; loginLocked = true; loginInput = ""; currentApp = nil; menuOpen = false
elseif apps[n] then app(apps[n]) end
end
return
end
if not currentApp then
if char == 27 then return end
if char == 109 or char == 77 then menuOpen = true
elseif char == 116 or char == 84 then app("terminal")
elseif char == 102 or char == 70 then app("files")
elseif char == 115 or char == 83 then app("system")
elseif char == 97 or char == 65 then app("account") end
return
end
if char == 27 or code == 1 then
if currentApp.name == "fileview" then
currentApp = {name = "files", title = "Files", path = parentPath(currentApp.path), entries = {}}
refreshEntries()
else currentApp = nil end
return
end
if currentApp.name == "terminal" then
if code == 200 then
-- Up arrow: recall the previous command, preserving the current draft.
currentApp.historyIndex = tonumber(currentApp.historyIndex) or (#history + 1)
if currentApp.historyIndex > #history + 1 then currentApp.historyIndex = #history + 1 end
if currentApp.historyIndex == #history + 1 then currentApp.draftInput = currentApp.input or "" end
if currentApp.historyIndex > 1 then
currentApp.historyIndex = currentApp.historyIndex - 1
currentApp.input = history[currentApp.historyIndex] or ""
end
elseif code == 208 then
-- Down arrow: move toward newer commands, then restore the draft line.
currentApp.historyIndex = tonumber(currentApp.historyIndex) or (#history + 1)
if currentApp.historyIndex < #history then
currentApp.historyIndex = currentApp.historyIndex + 1
currentApp.input = history[currentApp.historyIndex] or ""
else
currentApp.historyIndex = #history + 1
currentApp.input = currentApp.draftInput or ""
end
elseif char == 9 or code == 15 then
desktopState.completeTerminal()
elseif code == 28 or char == 13 then
local line = currentApp.input
currentApp.input = ""
currentApp.historyIndex = #history + 1
currentApp.draftInput = ""
if line ~= "" then
local wasWriteMode = currentApp.writeMode ~= nil
report((currentApp.writeMode and "write> " or ("root@minelinux:" .. currentApp.cwd .. "# ")) .. line, C.green)
runCommand(line)
local cmd = (parseArgs(line)[1] or ""):lower()
-- Only discard cached desktop discovery for operations that may change files.
if (wasWriteMode and (line == ".end" or line == ".cancel")) or
cmd == "write" or cmd == "touch" or cmd == "mkdir" or cmd == "rm" or
cmd == "cp" or cmd == "mv" or cmd == "lua" or cmd == "run" or cmd == "os" then
invalidateDesktopPrograms()
end
end
elseif code == 14 or char == 8 or char == 127 then
currentApp.input = currentApp.input:sub(1, -2)
currentApp.historyIndex = #history + 1
currentApp.draftInput = currentApp.input
elseif char >= 32 and char <= 126 then
currentApp.input = currentApp.input .. string.char(char)
currentApp.historyIndex = #history + 1
currentApp.draftInput = currentApp.input
end
elseif currentApp.name == "notepad" then
if currentApp.pathMode then
if char == 13 or code == 28 then
local mode, path = currentApp.pathMode, trim(currentApp.pathInput or "")
currentApp.pathMode = nil
if path == "" then currentApp.notice = "Path cannot be empty."; currentApp.noticeColor = C.red
else
path = resolve(path)
if mode == "open" then
local data = readTextSafe(path)
if data == nil then currentApp.notice = tr("no_file") .. ": " .. path; currentApp.noticeColor = C.red
elseif filesystem.isDirectory(path) then currentApp.notice = "Path is a directory."; currentApp.noticeColor = C.red
else currentApp.path = path; currentApp.contents = data; currentApp.modified = false; currentApp.notice = nil end
else
currentApp.path = path
local ok, err = writeTextSafe(path, currentApp.contents or "")
currentApp.notice = ok and tr("saved") or ("Save failed: " .. tostring(err))
currentApp.noticeColor = ok and C.green or C.red
if ok then currentApp.modified = false end
end
end
elseif char == 27 or code == 1 then currentApp.pathMode = nil
elseif char == 8 or char == 127 or code == 14 then currentApp.pathInput = usub(currentApp.pathInput or "", 1, ulen(currentApp.pathInput or "") - 1)
elseif char >= 32 then currentApp.pathInput = (currentApp.pathInput or "") .. (charToString and charToString(char) or string.char(math.min(255, char))) end
elseif char == 19 then
local ok, err = writeTextSafe(currentApp.path, currentApp.contents or "")
currentApp.notice = ok and tr("saved") or ("Save failed: " .. tostring(err)); currentApp.noticeColor = ok and C.green or C.red
if ok then currentApp.modified = false end
elseif code == 200 then
currentApp.scroll = math.max(1, (currentApp.scroll or 1) - 1)
elseif code == 208 then
local available = math.max(1, (currentApp.win and currentApp.win.h or select(2, resolution())) - 5)
local totalLines = #lineList(currentApp.contents)
currentApp.scroll = math.min(math.max(1, totalLines - available + 1), (currentApp.scroll or 1) + 1)
elseif char == 13 or code == 28 then
currentApp.contents = (currentApp.contents or "") .. "\n"; currentApp.modified = true; currentApp.notice = nil
elseif char == 8 or char == 127 or code == 14 then
local content = currentApp.contents or ""
if ulen(content) > 0 then currentApp.contents = usub(content, 1, ulen(content) - 1); currentApp.modified = true; currentApp.notice = nil end
elseif char >= 32 then
local ctext = charToString and charToString(char) or (char <= 255 and string.char(char) or "")
if ctext ~= "" then currentApp.contents = (currentApp.contents or "") .. ctext; currentApp.modified = true; currentApp.notice = nil end
end
elseif currentApp.name == "calculator" then
if char == 13 or code == 28 then
local value, err = evaluateArithmetic(currentApp.input)
currentApp.result = value ~= nil and tostring(value) or ("Error: " .. tostring(err))
elseif char == 8 or char == 127 or code == 14 then currentApp.input = usub(currentApp.input or "", 1, ulen(currentApp.input or "") - 1)
elseif char >= 32 then
local ctext = charToString and charToString(char) or (char <= 255 and string.char(char) or "")
if ctext:match("^[%d%.%+%-%*/%^%(%)%%]$") then currentApp.input = (currentApp.input or "") .. ctext end
end
elseif currentApp.name == "calendar" then
if code == 203 then currentApp.monthOffset = (currentApp.monthOffset or 0) - 1
elseif code == 205 then currentApp.monthOffset = (currentApp.monthOffset or 0) + 1 end
elseif currentApp.name == "paint" then
-- Painting is mouse-driven; keyboard shortcuts: S save, L load, C clear.
if char == 115 or char == 83 then savePaintFile()
elseif char == 108 or char == 76 then loadPaintFile()
elseif char == 99 or char == 67 then currentApp.canvas = {}; currentApp.notice = nil end
elseif currentApp.name == "launcher" then
if char >= 49 and char <= 53 then
local choices = {"notepad", "paint", "calculator", "calendar", "sandbox"}
app(choices[char - 48])
end
elseif currentApp.name == "account" then
local form = currentApp.form
if char == 13 or code == 28 then
if currentApp.editField == "username" then currentApp.editField = "avatar"; form.avatar = ""
elseif currentApp.editField == "avatar" then currentApp.editField = "pass1"; form.pass1 = ""
elseif currentApp.editField == "pass1" then currentApp.editField = "pass2"; form.pass2 = ""
elseif currentApp.editField == "pass2" then
local h = hits
for i = #h, 1, -1 do if h[i].action == "accountsave" then handleAction(h[i]); break end end
end
elseif char == 8 or char == 127 or code == 14 then
local field = currentApp.editField
if field and form[field] then form[field] = form[field]:sub(1, -2) end
elseif char >= 32 and char <= 126 then
local field = currentApp.editField
if field == "username" then
if #form.username < 24 then form.username = (form.username or "") .. string.char(char) end
elseif field == "avatar" then
if #form.avatar < 2 then form.avatar = (form.avatar or "") .. string.char(char) end
elseif field == "pass1" or field == "pass2" then
if #form[field] < 64 then form[field] = form[field] .. string.char(char) end
end
end
elseif currentApp.name == "users" then
if currentApp.pendingDelete then
if char == 121 or char == 89 then
local index = currentApp.pendingDelete
if index and users[index] and index ~= activeUserIndex and #users > 1 then
local removed = table.remove(users, index)
local oldActive = activeUserIndex
if activeUserIndex > index then activeUserIndex = activeUserIndex - 1 end
local ok, err = saveUsers()
if not ok then
table.insert(users, index, removed)
activeUserIndex = oldActive
currentApp.userSelected = index
currentApp.notice = "Save failed: " .. tostring(err); currentApp.noticeColor = C.red
else
currentApp.userSelected = math.max(1, math.min(#users, index))
currentApp.notice = "Account deleted."; currentApp.noticeColor = C.green
end
end
currentApp.pendingDelete = nil
elseif char == 110 or char == 78 or char == 27 or code == 1 then
currentApp.pendingDelete = nil; currentApp.notice = nil
end
elseif currentApp.usersMode == "new" then
local form = currentApp.userForm or {}
local fields = {"username", "avatar", "pass1", "pass2"}
local fieldIndex = 1
for i, key in ipairs(fields) do if key == currentApp.editField then fieldIndex = i; break end end
if char == 13 or code == 28 then
if fieldIndex < #fields then currentApp.editField = fields[fieldIndex + 1]
else
local actionHit = {action = "usersaves"}; handleAction(actionHit)
end
elseif char == 27 or code == 1 then currentApp.usersMode = "list"; currentApp.userForm = nil; currentApp.notice = nil
elseif char == 8 or char == 127 or code == 14 then
local key = currentApp.editField; if key and form[key] then form[key] = form[key]:sub(1, -2) end
elseif char >= 32 and char <= 126 then
local key = currentApp.editField
local limit = key == "username" and 24 or (key == "avatar" and 2 or 64)
if key and form[key] and #form[key] < limit then form[key] = form[key] .. string.char(char) end
end
else
if char == 110 or char == 78 then handleAction({action = "usernew"})
elseif char == 100 or char == 68 or char == 120 or char == 88 then handleAction({action = "userdelete"})
elseif code == 200 then currentApp.userSelected = math.max(1, (currentApp.userSelected or 1) - 1)
elseif code == 208 then currentApp.userSelected = math.min(#users, (currentApp.userSelected or 1) + 1)
elseif char == 13 or code == 28 then
selectedLoginIndex = currentApp.userSelected or activeUserIndex
loginInput, loginMessage = "", ""; authMode = "login"; loginLocked = true; currentApp = nil; menuOpen = false
elseif char == 27 or code == 1 then currentApp = nil
end
end
elseif currentApp.name == "settings" then
local tabs = {"appearance", "clock", "language", "account", "system", "memory"}
if char >= 49 and char <= 54 then currentApp.settingsTab = tabs[char - 48]
elseif char == 27 then currentApp = nil end
elseif currentApp.name == "files" then
if currentApp.inputMode then
if char == 13 or code == 28 then
commitFilePrompt()
elseif char == 27 or code == 1 then
currentApp.inputMode = nil
fileNotice("Action cancelled.", C.yellow)
elseif char == 8 or char == 127 or code == 14 then
currentApp.inputMode.buffer = currentApp.inputMode.buffer:sub(1, -2)
elseif char >= 32 and char <= 126 then
if #currentApp.inputMode.buffer < 64 then currentApp.inputMode.buffer = currentApp.inputMode.buffer .. string.char(char) end
end
elseif char == 13 or code == 28 then
local selected = selectedFilePath()
if selected then openFileEntry(selected) end
elseif code == 200 then
currentApp.selected = currentApp.selectedPath and math.max(1, (currentApp.selected or 1) - 1) or 1
local e = currentApp.entries[currentApp.selected]
if e then currentApp.selectedPath = filesystem.canonical((currentApp.path == "/" and "" or currentApp.path) .. "/" .. e:gsub("/$", "")) end
elseif code == 208 then
currentApp.selected = currentApp.selectedPath and math.min(#currentApp.entries, (currentApp.selected or 1) + 1) or 1
local e = currentApp.entries[currentApp.selected]
if e then currentApp.selectedPath = filesystem.canonical((currentApp.path == "/" and "" or currentApp.path) .. "/" .. e:gsub("/$", "")) end
elseif char == 46 then
currentApp.path = parentPath(currentApp.path); currentApp.selected = 1; currentApp.selectedPath = nil; refreshEntries()
elseif char == 110 or char == 78 then startFilePrompt("newfile")
elseif char == 100 or char == 68 then startFilePrompt("newdir")
elseif char == 114 or char == 82 then startFilePrompt("rename")
elseif char == 120 or char == 88 then startFilePrompt("delete")
elseif char == 99 or char == 67 then startFilePrompt("copy")
elseif char == 107 or char == 75 then startFilePrompt("cut")
elseif char == 118 or char == 86 then startFilePrompt("paste")
elseif char == 109 or char == 77 then currentApp.contextMenu = {x = 3, y = 4}
elseif char == 102 or char == 70 then startFilePrompt("refresh")
end
end
end
local function handleClipboard(value)
value = tostring(value or "")
if value == "" then return end
value = value:gsub("\r\n", "\n"):gsub("\r", "\n")
if loginLocked then
local pasted = value:gsub("\n", "")
loginInput = (loginInput or "") .. pasted
if #loginInput > 128 then loginInput = loginInput:sub(1, 128) end
loginMessage = ""
if authMode == "setup" and setupState then
local key = ({"username", "avatar", "pass1", "pass2"})[setupState.field or 1]
if key then setupState[key] = ((setupState[key] or "") .. pasted):sub(1, key == "username" and 24 or (key == "avatar" and 2 or 64)) end
end
return
end
if not currentApp then return end
if currentApp.name == "notepad" then
if currentApp.pathMode then
currentApp.pathInput = ((currentApp.pathInput or "") .. value:gsub("\n", " ")):sub(1, 255)
else
currentApp.contents = (currentApp.contents or "") .. value
currentApp.modified = true
currentApp.notice = nil
end
elseif currentApp.name == "terminal" then
-- Do not execute pasted lines automatically: make it one editable command line.
currentApp.input = (currentApp.input or "") .. value:gsub("\n+", " ")
elseif currentApp.name == "calculator" then
local filtered = value:gsub("[^%d%.%+%-%*/%^%(%)%%%s]", ""):gsub("\n", " ")
currentApp.input = (currentApp.input or "") .. filtered
elseif currentApp.name == "files" and currentApp.inputMode then
currentApp.inputMode.buffer = ((currentApp.inputMode.buffer or "") .. value:gsub("\n+", " ")):sub(1, 128)
elseif currentApp.name == "account" then
local form = currentApp.form or {}
local field = currentApp.editField
if field and form[field] ~= nil then form[field] = (form[field] .. value:gsub("\n", "")):sub(1, field == "username" and 24 or (field == "avatar" and 2 or 64)) end
elseif currentApp.name == "users" and currentApp.usersMode == "new" then
local form = currentApp.userForm or {}
local field = currentApp.editField
if field and form[field] ~= nil then form[field] = (form[field] .. value:gsub("\n", "")):sub(1, field == "username" and 24 or (field == "avatar" and 2 or 64)) end
end
end
-- BIOS-like identity/profile guard. This deliberately labels the EEPROM and
-- installs a salted integrity record; it does NOT overwrite the physical EEPROM
-- boot code, which could make an OpenComputers machine unbootable.
local function ensureBiosIdentity()
pcall(filesystem.makeDirectory, "/home/MineLinux")
local eeprom = nil
if component.isAvailable("eeprom") then pcall(function() eeprom = component.eeprom end) end
if eeprom then
local ok, label = pcall(eeprom.getLabel)
if ok and label == BIOS_LABEL then
biosLabelStatus = "already " .. BIOS_LABEL
else
local setOk = pcall(eeprom.setLabel, BIOS_LABEL)
local verifyOk, verifyLabel = pcall(eeprom.getLabel)
if setOk and verifyOk and verifyLabel == BIOS_LABEL then biosLabelStatus = "renamed to " .. BIOS_LABEL
else biosLabelStatus = "could not rename EEPROM label" end
end
end
if filesystem.exists(BIOS_PROFILE_PATH) then
local data = readTextSafe(BIOS_PROFILE_PATH) or ""
local version = data:match("version=([^\r\n]+)")
local salt = data:match("salt=([^\r\n]+)")
local signature = data:match("signature=([^\r\n]+)")
local expected = salt and hashPassword("MineLinux-BIOS-profile:v1:" .. salt .. ":" .. BIOS_LABEL) or nil
if version == "1" and expected and signature == expected then
biosStatus = "verified; no reflash needed"
else
biosStatus = "integrity warning"
biosWarning = "BIOS profile signature mismatch; profile was left untouched."
end
else
math.randomseed(math.floor((computer.uptime() or 0) * 100000) + 2718)
local salt = string.format("%x-%06x-%04x", math.floor((computer.uptime() or 0) * 100000), math.random(0, 0xFFFFFF), math.random(0, 0xFFFF))
local signature = hashPassword("MineLinux-BIOS-profile:v1:" .. salt .. ":" .. BIOS_LABEL)
local ok, err = writeTextSafe(BIOS_PROFILE_PATH, "label=" .. BIOS_LABEL .. "\nversion=1\nsalt=" .. salt .. "\nsignature=" .. signature .. "\n")
if ok then biosStatus = "initialized; salted signature created"
else biosStatus = "profile not installed"; biosWarning = tostring(err) end
end
end
local function bootAnimation()
if not gpu then return end
local sw, sh = resolution()
local label = "MineLinux " .. VERSION
for frame = 1, 5 do
fillRect(1, 1, sw, sh, C.wallpaper)
local titleY = math.max(2, math.floor(sh / 2) - 2)
centerText(titleY, "M I N E L I N U X", C.green, C.wallpaper)
centerText(titleY + 1, "CINNAMON DESKTOP", C.muted, C.wallpaper)
local barW = math.max(8, math.min(sw - 8, math.floor(sw * 0.58)))
local barX = math.max(1, math.floor((sw - barW) / 2) + 1)
local barY = math.min(sh - 2, titleY + 4)
fillRect(barX, barY, barW, 1, C.panelAlt)
fillRect(barX, barY, math.max(1, math.floor(barW * frame / 5)), 1, C.green)
centerText(math.min(sh - 1, barY + 1), frame == 5 and "Checking system profile..." or label, C.text, C.wallpaper)
if backBuffer then
local copied = pcall(function()
gpu.setActiveBuffer(0)
gpu.bitblt(0, 1, 1, sw, sh, backBuffer, 1, 1)
gpu.setActiveBuffer(backBuffer)
end)
if not copied then pcall(gpu.setActiveBuffer, 0) end
end
pcall(computer.beep, 420 + frame * 75, 0.025)
pcall(event.pull, 0.055)
end
end
local function initialize()
if not gpu then
print("MineLinux Cinnamon Desktop " .. VERSION)
print("No GPU is available. Please connect a GPU and screen to use the graphical desktop.")
print("Run the previous MineLinux terminal from OpenOS if needed.")
return false
end
if not filesystem.exists(cwd) or not filesystem.isDirectory(cwd) then cwd = "/" end
for _, dir in ipairs({DESKTOP_PATH, VMFS_ROOT, "/home/MineLinux/Documents", "/home/MineLinux/Downloads", "/home/MineLinux/Pictures"}) do
pcall(filesystem.makeDirectory, dir)
end
ensureBiosIdentity()
if screen then pcall(function() screen.setTouchModeInverted(false) end) end
return true
end
if not initialize() then return end
-- Prevent the stock OpenOS interrupt shortcuts from killing the desktop loop.
-- If this OpenOS version does not expose a process signal hook, the event-loop
-- fallback below still catches the hard-interrupt error.
installInterruptGuard()
-- Prefer double-buffered rendering. If the GPU lacks memory/API support, the
-- desktop still runs using the ordinary screen buffer.
configureBackBuffer(true)
bootAnimation()
-- Low-activity event loop with cached desktop file discovery: wait longer when idle, and cap full-frame redraws
-- while dragging/resizing windows or desktop shortcuts. Input events still
-- wake event.pull immediately; idle polling is not a busy-spin loop.
local dirty = true
local lastClock = clockRefreshKey()
local runtimeError = nil
local lastRedrawAt = 0
while running do
if dirty then
local now = computer.uptime()
if not dragState or now - lastRedrawAt >= ((dragState and dragState.mode == "paintstroke") and 0.035 or 0.08) then
redraw()
dirty = false
lastRedrawAt = now
end
end
-- Idle: wait up to 1 second instead of waking four times per second.
-- Paint strokes poll more frequently; other drags keep the lower-activity timeout.
-- Input events still wake event.pull immediately.
local pullTimeout = dragState and dragState.mode == "paintstroke" and 0.02 or (dragState and 0.06 or 1.0)
-- OpenOS event.pull may THROW "interrupted" (for Ctrl+Alt+C), rather than
-- returning an "interrupted" event. Catch it so GPU buffers are still freed
-- and the terminal is restored before we return to OpenOS.
local pullOk, signal, address, p1, p2, p3 = pcall(event.pull, pullTimeout)
if not pullOk then
local reason = tostring(signal or "unknown event error")
if reason:lower():find("interrupted", 1, true) then
-- Ctrl+Alt+C raises an "interrupted" error from event.pull on OpenOS.
-- Swallow only that interrupt and keep the desktop alive.
signal = nil
if currentApp then
currentApp.notice = "Ctrl+Alt+C is disabled while MineLinux is running. Use `openos` to return normally."
currentApp.noticeColor = C.yellow
end
dirty = true
else
runtimeError = reason
running = false
signal = nil
end
end
if signal == "touch" then
-- Handle a click only once. OpenComputers may send drag/drop signals for
-- the same gesture; treating those as clicks toggled the Start menu shut.
local x, y = tonumber(p1), tonumber(p2)
if x and y then
local touched = hitTest(hits, x, y)
-- Right-click a Lua desktop item for Open / Remove from desktop.
if touched and touched.action == "desktopProgram" and tonumber(p3) == 1 then
desktopState.contextMenu = {x = x, y = y, item = touched.value}
desktopState.selectedEntry = touched.value and touched.value.entryName or nil
dragState = nil
elseif desktopState.contextMenu then
if touched and (touched.action == "desktopctxaction" or touched.action == "desktopctxclose") then
handleAction(touched, x, y)
else
desktopState.contextMenu = nil
end
-- Open the file context menu with a right-click where the screen supports it.
elseif currentApp and currentApp.name == "files" and tonumber(p3) == 1 then
if touched and touched.action == "fileentry" then
currentApp.selectedPath = touched.value
for i, entry in ipairs(currentApp.entries) do
local child = filesystem.canonical((currentApp.path == "/" and "" or currentApp.path) .. "/" .. entry:gsub("/$", ""))
if child == touched.value then currentApp.selected = i; break end
end
end
currentApp.contextMenu = {x = x, y = y}
-- Clicking the panel menu remains available even while a window is open.
elseif not loginLocked and y == select(2, resolution()) and x <= 10 then
menuOpen = not menuOpen
elseif touched and touched.action == "desktopProgram" then
local item = touched.value
desktopState.selectedEntry = item and item.entryName or nil
desktopState.contextMenu = nil
if item then
local now = computer.uptime()
if desktopState.lastClickEntry == item.entryName and now - (desktopState.lastClickTime or 0) <= 0.60 then
-- Double-click opens; a single press can safely become a drag.
desktopState.lastClickEntry, desktopState.lastClickTime = nil, 0
dragState = nil
desktopState.openProgram(item)
else
desktopState.lastClickEntry, desktopState.lastClickTime = item.entryName, now
dragState = {mode = "desktopItem", item = item, key = item.entryName, startX = x, startY = y,
originX = item.x, originY = item.y, moved = false}
end
end
else
if currentApp and currentApp.name == "files" and currentApp.contextMenu and (not touched or touched.action ~= "fileaction") then
currentApp.contextMenu = nil
end
handleAction(touched, x, y)
end
end
dirty = true
elseif signal == "drag" then
local x, y = tonumber(p1), tonumber(p2)
if dragState and dragState.mode == "paintstroke" and currentApp and currentApp.name == "paint" then
local cellX, cellY = desktopState.paintStrokeToScreen(x, y, dragState.lastX, dragState.lastY, dragState.color)
if cellX and cellY then
dragState.lastX, dragState.lastY = cellX, cellY
currentApp.notice = nil
dirty = true
end
elseif dragState and dragState.mode == "desktopItem" then
if x and y then
local dx, dy = x - dragState.startX, y - dragState.startY
if math.abs(dx) + math.abs(dy) >= 1 then dragState.moved = true end
if dragState.moved then
local sw, sh = resolution()
local nx = math.max(1, math.min(math.max(1, sw - 10), dragState.originX + dx))
local ny = math.max(2, math.min(math.max(2, sh - 4), dragState.originY + dy))
desktopState.layout[dragState.key] = {x = nx, y = ny}
desktopState.lastClickEntry, desktopState.lastClickTime = nil, 0
dirty = true
end
end
elseif dragState and dragState.mode == "desktopShortcut" then
if x and y and (math.abs(x - dragState.startX) + math.abs(y - dragState.startY) >= 1) then dragState.moved = true end
elseif dragState and currentApp and currentApp.win then
if x and y then
local sw, sh = resolution()
local win = currentApp.win
local workspaceH = math.max(1, sh - 1)
if not win.maximized then
if dragState.mode == "move" then
win.x = math.max(1, math.min(sw - win.w + 1, dragState.x + x - dragState.startX))
win.y = math.max(1, math.min(workspaceH - win.h + 1, dragState.y + y - dragState.startY))
elseif dragState.mode == "resize" then
win.w = math.max(math.min(sw, 24), math.min(sw - win.x + 1, dragState.w + x - dragState.startX))
win.h = math.max(math.min(workspaceH, 8), math.min(workspaceH - win.y + 1, dragState.h + y - dragState.startY))
end
end
appWindows[currentApp.name] = {x = win.x, y = win.y, w = win.w, h = win.h, maximized = win.maximized == true}
dirty = true
end
end
elseif signal == "drop" then
if dragState and dragState.mode == "desktopItem" then
local item = dragState.item
if dragState.moved then
local ok = desktopState.saveLayout()
desktopState.notice = ok and (prefs.language == "ru" and "Положение значка сохранено" or "Icon position saved") or desktopState.notice
end
elseif dragState and dragState.mode == "desktopShortcut" and dragState.moved then
local x, y = tonumber(p1), tonumber(p2)
local onDesktop = true
if x and y and currentApp and currentApp.win then
local wx, wy, ww, wh = windowRect(currentApp)
onDesktop = not (x >= wx and x < wx + ww and y >= wy and y < wy + wh)
end
if onDesktop then
local ok, message = createDesktopShortcut(dragState.source)
if currentApp and currentApp.name == "files" then fileNotice(message, ok and C.green or C.red) end
end
end
dragState = nil
dirty = true
elseif signal == "key_down" then
handleKey(tonumber(p1) or 0, tonumber(p2) or 0)
dirty = true
elseif signal == "clipboard" then
-- OpenComputers emits this when the player pastes from the host clipboard
-- using the middle mouse button or Shift+Insert. p1 is the pasted text.
handleClipboard(p1)
dirty = true
elseif signal == "screen_resize" or signal == "screen_resized" then
configureBackBuffer(true)
dirty = true
elseif signal == "interrupted" then
-- Ctrl+C is a soft interrupt event in OpenOS. Ignore it inside MineLinux.
if currentApp then
currentApp.notice = "Ctrl+C is disabled while MineLinux is running. Use `openos` to return normally."
currentApp.noticeColor = C.yellow
end
dirty = true
end
-- Refresh the clock without continuously repainting the desktop.
local clockNow = clockRefreshKey()
if clockNow ~= lastClock then
lastClock = clockNow
dirty = true
end
end
-- Restore the original interrupt handler when MineLinux exits normally.
restoreInterruptGuard()
-- Restore the visible screen buffer before returning control to OpenOS. If a
-- non-zero buffer stayed active, the shell could continue drawing off-screen.
if gpu and backBuffer then
pcall(gpu.setActiveBuffer, 0)
pcall(gpu.freeBuffer, backBuffer)
backBuffer = nil
end
pcall(function()
term.setTextColor(0xFFFFFF)
term.setBackgroundColor(0x000000)
term.clear()
term.setCursor(1, 1)
end)
if runtimeError then
print("MineLinux stopped because event handling failed:")
print(tostring(runtimeError):sub(1, 240))
elseif leavingToOpenOS then
print("Returned to the native OpenOS shell.")
print("To start MineLinux again, run: lua /home/minelinux.lua")
end
-- END OF MINELINUX SOURCE