Загрузка данных


-- 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