-- ============================================================= -- OCBrowser v2.0 | Браузер для OpenComputers -- Требования: Internet Card, GPU Tier 2+, Screen Tier 2+ -- ============================================================= local component = require("component") local term = require("term") local gpu = component.gpu local event = require("event") local unicode = require("unicode") local keyboard = require("keyboard") local text = require("text") local computer = require("computer") local _okFs, fs = pcall(require, "filesystem") if not _okFs then fs = nil end -- ── проверка internet card ──────────────────────────────────── if not component.isAvailable("internet") then io.stderr:write("Ошибка: Internet Card не найдена!\n") os.exit(1) end local inet = component.internet -- ── размеры экрана ─────────────────────────────────────────── local W, H = gpu.getResolution() local PAGE_Y = 4 -- начало области страницы (строка) local PAGE_H = H - PAGE_Y -- количество строк страницы local PAGE_W = W - 2 -- с учётом скроллбара -- ── цветовая палитра ───────────────────────────────────────── local C = { bg = 0x1a1a2e, bar = 0x16213e, urlBox = 0x0f3460, urlText = 0xe0e0e0, accent = 0x533483, btnBg = 0x0f3460, btnFg = 0xffffff, btnHover = 0x533483, pageBg = 0x1e1e2e, pageText = 0xcdd6f4, heading = 0xcba6f7, link = 0x89b4fa, linkVisit = 0xb4befe, error = 0xf38ba8, status = 0xa6adc8, scrollbar = 0x313244, scrollFg = 0x585b70, select = 0x313244, } -- ── состояние браузера ──────────────────────────────────────── local state = { url = "about:home", history = {}, histPos = 0, lines = {}, -- отрендеренные строки {text, color, isLink, href} scroll = 0, urlBar = "", urlFocus = false, urlCursor = 0, loading = false, loadProgress = nil, loadReceived = 0, loadTotal = 0, fetchJob = nil, statusMsg = "Добро пожаловать в OCBrowser", links = {}, -- список ссылок на странице selLink = -1, pageH = H - 4, -- высота области страницы forms = {}, -- формы текущей страницы inputs = {}, -- поля ввода (любой формы) inputEdit = nil, -- {idx, buf, cursor} когда активно редактирование } -- ── домашняя страница ──────────────────────────────────────── local HOME_PAGE = [[ OCBrowser — Домашняя

OCBrowser 2.1

Добро пожаловать!

Текстовый браузер для OpenComputers. Является «Lynx-подобным» — JS не выполняется.

Поиск (работает без JS)

Совет: добавьте в запрос !g, !w, !yt — DDG-бэнги для Google/Wikipedia/YouTube.

Reader Mode (для JS-сайтов)

На любой странице нажмите Ctrl+R — страница будет перезагружена через r.jina.ai, который исполнит JS на своём сервере и отдаст чистый Markdown. Работает для Telegram, Twitter, Reddit и большинства SPA.

[Пример] web.telegram.org через Reader

Быстрые ссылки (работают без JS)

Поддержка разметки

Теперь работает: жирный, курсив, моноширинный код, подчёркивание, выделение, зачёркнутый, ссылки внутри абзаца.

«Цитата выглядит как блок с вертикальной полосой слева».

Нумерованный список:

  1. Первый пункт
  2. Второй пункт с ссылкой
  3. Третий пункт

Блок <pre> сохраняет пробелы и переносы:

  function hello()
    return "world"
  end

Изображения рендерятся в реальном времени (PNG → inflate → ½-блоки):

libpng логотип (HTTP, прямая)

PNG suite — 8-bit RGB

Кот через wsrv (если доверенный сертификат)


Управление

F5 — обновить  |  Alt+Left — назад  |  Alt+Right — вперёд

Tab — следующая ссылка  |  Enter — открыть ссылку / URL

PgUp/PgDn или стрелки — прокрутка  |  Ctrl+L — адресная строка

]] -- ═══════════════════════════════════════════════════════════════ -- УТИЛИТЫ ОТРИСОВКИ -- ═══════════════════════════════════════════════════════════════ local function setFg(c) gpu.setForeground(c) end local function setBg(c) gpu.setBackground(c) end local drawPage local renderPage processNextImage = nil -- объявление в общем пространстве (присваивается позже) local function fill(x, y, w, h, ch) gpu.fill(x, y, w, h, ch or " ") end local function writeAt(x, y, str, fg, bg) if fg then setFg(fg) end if bg then setBg(bg) end gpu.set(x, y, str) end local function clampStr(s, maxW) if unicode.len(s) <= maxW then return s end return unicode.sub(s, 1, maxW - 1) .. "…" end local function center(s, w) local l = unicode.len(s) if l >= w then return unicode.sub(s, 1, w) end local pad = math.floor((w - l) / 2) return string.rep(" ", pad) .. s .. string.rep(" ", w - l - pad) end -- ═══════════════════════════════════════════════════════════════ -- ТОП-БАР -- ═══════════════════════════════════════════════════════════════ local BTN = { { label = " ◄ ", key = "back", x = 2 }, { label = " ► ", key = "fwd", x = 6 }, { label = " ↺ ", key = "reload", x = 10 }, { label = " ⌂ ", key = "home", x = 14 }, } local URL_X = 19 local URL_W = W - 22 local GO_X = W - 2 local function drawBar() setBg(C.bar) setFg(C.urlText) fill(1, 1, W, 1) fill(1, 2, W, 1) fill(1, 3, W, 1) -- кнопки навигации for _, b in ipairs(BTN) do local active = true if b.key == "back" then active = state.histPos > 1 end if b.key == "fwd" then active = state.histPos < #state.history end setBg(C.btnBg) setFg(active and C.btnFg or C.status) gpu.set(b.x, 2, b.label) end -- адресная строка / промпт инпута local ie = state.inputEdit local inEdit = ie ~= nil local boxBg = (state.urlFocus or inEdit) and C.accent or C.urlBox setBg(boxBg) setFg(C.urlText) fill(URL_X, 2, URL_W, 1) local displayText, cursorPos if inEdit then local rec = state.inputs[ie.idx] local label = "[" .. (rec and rec.name ~= "" and rec.name or ("input #" .. ie.idx)) .. "] " displayText = label .. ie.buf cursorPos = unicode.len(label) + ie.cursor elseif state.urlFocus then displayText = state.urlBar cursorPos = state.urlCursor else displayText = state.url cursorPos = nil end local shown = clampStr(displayText, URL_W - 2) gpu.set(URL_X + 1, 2, shown) if cursorPos then local cx = URL_X + 1 + math.min(cursorPos, URL_W - 3) setBg(C.urlText) setFg(boxBg) local src = inEdit and ie.buf or state.urlBar local off = inEdit and (cursorPos) or state.urlCursor local ch if inEdit then ch = unicode.sub(ie.buf, ie.cursor + 1, ie.cursor + 1) else ch = unicode.sub(state.urlBar, state.urlCursor + 1, state.urlCursor + 1) end gpu.set(cx, 2, ch == "" and " " or ch) end -- кнопка GO setBg(C.accent) setFg(C.btnFg) gpu.set(GO_X, 2, " ▶") -- строка статуса setBg(C.bar) setFg(C.status) fill(1, 3, W, 1) local loading = state.loading and "" or "" local left = clampStr(state.statusMsg .. loading, W - 2) gpu.set(2, 3, left) if state.loading then local barW = math.min(26, math.max(10, math.floor(W * 0.22))) local barX = W - barW - 1 local filled = 0 local suffix = "" if type(state.loadProgress) == "number" then filled = math.floor((barW - 2) * math.max(0, math.min(1, state.loadProgress))) suffix = string.format(" %3d%%", math.floor(state.loadProgress * 100 + 0.5)) else local phase = (state.loadReceived or 0) % math.max(1, (barW - 2)) filled = math.max(1, phase) suffix = " ..." end setBg(C.bar) setFg(C.status) gpu.set(barX, 3, "[") gpu.set(barX + barW - 1, 3, "]") setBg(C.scrollFg) setFg(C.btnFg) if filled > 0 then gpu.set(barX + 1, 3, string.rep(" ", math.min(barW - 2, filled))) end setBg(C.bar) setFg(C.status) local rest = (barW - 2) - math.min(barW - 2, filled) if rest > 0 then gpu.set(barX + 1 + math.min(barW - 2, filled), 3, string.rep(" ", rest)) end gpu.set(math.max(2, barX - unicode.len(suffix)), 3, suffix) end end -- ═══════════════════════════════════════════════════════════════ -- РАЗБОР HTML → строки -- ═══════════════════════════════════════════════════════════════ -- ── HTML entities ───────────────────────────────────────────── local ENTITIES = { amp="&", lt="<", gt=">", quot='"', apos="'", nbsp=" ", mdash="—", ndash="–", hellip="…", laquo="«", raquo="»", copy="©", reg="®", trade="™", euro="€", pound="£", yen="¥", cent="¢", sect="§", para="¶", middot="·", bull="•", deg="°", larr="←", rarr="→", uarr="↑", darr="↓", harr="↔", lsquo="‘", rsquo="’", ldquo="“", rdquo="”", sbquo="‚", bdquo="„", times="×", divide="÷", plusmn="±", ["ne"]="≠", le="≤", ge="≥", infin="∞", sum="∑", radic="√", micro="µ", } local function htmlDecode(s) s = s:gsub("&#(%d+);", function(n) local v = tonumber(n); if not v then return "" end local ok, ch = pcall(unicode.char, v); return ok and ch or "" end) s = s:gsub("&#[xX](%x+);", function(n) local v = tonumber(n, 16); if not v then return "" end local ok, ch = pcall(unicode.char, v); return ok and ch or "" end) s = s:gsub("&(%a+);", function(name) return ENTITIES[name] or ("&" .. name .. ";") end) return s end -- ── токенизатор HTML → плоский поток событий ────────────────── local function tokenizeHTML(html) local tokens = {} local stack = { { fg=nil, bg=nil, bold=false, italic=false, code=false, underline=false, strike=false, mark=false, small=false, link=nil, heading=0, preformat=false, skip=false } } local function cur() return stack[#stack] end local function push(mod) local top = stack[#stack] local nw = {} for k,v in pairs(top) do nw[k] = v end for k,v in pairs(mod) do nw[k] = v end stack[#stack+1] = nw end local function pop() if #stack > 1 then stack[#stack] = nil end end local inHead, inScript, inStyle = false, false, false local listTypes = {} -- стек: "ul"/"ol" local olCounters = {} -- формы / инпуты local forms = {} local inputs = {} local currentFormIdx = nil local function regInput(rec) inputs[#inputs + 1] = rec rec._idx = #inputs if currentFormIdx and forms[currentFormIdx] then local f = forms[currentFormIdx] f.inputs[#f.inputs + 1] = rec._idx rec.formIdx = currentFormIdx end return rec._idx end local function emitText(t) if t == "" then return end tokens[#tokens+1] = { kind="text", text=t, style=cur() } end local function emit(kind, extra) local t = extra or {} t.kind = kind tokens[#tokens+1] = t end local pos = 1 local len = #html while pos <= len do local tagStart = html:find("<", pos, true) if not tagStart then if not (inHead or inScript or inStyle) then emitText(htmlDecode(html:sub(pos))) end break end if tagStart > pos and not (inHead or inScript or inStyle) then emitText(htmlDecode(html:sub(pos, tagStart - 1))) end -- комментарий if html:sub(tagStart, tagStart+3) == "", tagStart+4, true) if not cend then break end pos = cend + 3 else local tagEnd = html:find(">", tagStart, true) if not tagEnd then break end local raw = html:sub(tagStart+1, tagEnd-1) pos = tagEnd + 1 -- самозакрывающийся
local selfClose = raw:sub(-1) == "/" if selfClose then raw = raw:sub(1, -2):gsub("%s+$", "") end local isClose = raw:sub(1,1) == "/" local tname = raw:match("^/?%s*([%w!]+)") or "" tname = tname:lower() local function attr(name) return raw:match(name .. '%s*=%s*"([^"]*)"') or raw:match(name .. "%s*=%s*'([^']*)'") or raw:match(name .. "%s*=%s*([^%s>]+)") end if tname == "head" then inHead = not isClose elseif tname == "script" then inScript = not isClose elseif tname == "style" then inStyle = not isClose elseif inHead or inScript or inStyle then -- пропускаем содержимое elseif tname == "br" then emit("br") elseif tname == "hr" then emit("hr") elseif tname == "img" then emit("img", { src = attr("src") or "", alt = attr("alt") or "", title = attr("title") }) elseif tname == "p" or tname == "div" or tname == "section" or tname == "article" or tname == "header" or tname == "footer" or tname == "main" or tname == "nav" or tname == "aside" or tname == "figure" or tname == "figcaption" or tname == "address" or tname == "dl" or tname == "dt" or tname == "dd" then emit("parabr") if not isClose then local mods = {} local al = attr("align") if al then mods.align = al:lower() end push(mods) else pop() end elseif tname == "form" then emit("parabr") if not isClose then forms[#forms + 1] = { action = attr("action") or "", method = (attr("method") or "get"):lower(), inputs = {}, enctype = (attr("enctype") or "application/x-www-form-urlencoded"):lower(), } currentFormIdx = #forms push{} -- form-scope else pop() currentFormIdx = nil emit("parabr") end elseif tname == "input" then if not (inHead or inScript or inStyle) then local itype = (attr("type") or "text"):lower() local rec = { type = itype, name = attr("name") or "", value = attr("value") or "", placeholder = attr("placeholder") or "", size = tonumber(attr("size")) or 0, checked = attr("checked") ~= nil, } local idx = regInput(rec) if itype == "hidden" then -- невидимое поле elseif itype == "submit" or itype == "button" or itype == "image" or itype == "reset" then -- кнопка: рендерим текстовый лейбл как ссылку-сабмит local label = "[ " .. (rec.value ~= "" and rec.value or (itype == "submit" and "Отправить" or "Кнопка")) .. " ]" local href = (itype == "reset") and ("__reset:" .. idx) or ("__submit:" .. idx) push{ link = href, button = true } emitText(label) pop() else -- текстовое поле local sz = rec.size if sz <= 0 then sz = 30 end if sz > 60 then sz = 60 end if sz < 4 then sz = 4 end rec.size = sz emit("input_field", { idx = idx, style = cur() }) end end elseif tname == "textarea" then if not isClose then local rec = { type = "textarea", name = attr("name") or "", value = "", placeholder = attr("placeholder") or "", size = tonumber(attr("cols")) or 40, checked = false, } if rec.size > 60 then rec.size = 60 end if rec.size < 8 then rec.size = 8 end local idx = regInput(rec) emit("input_field", { idx = idx, style = cur() }) push{ skip = true } -- игнорируем дефолтный текст внутри else pop() end elseif tname == "button" then if not isClose then local btype = (attr("type") or "submit"):lower() local rec = { type = btype, name = attr("name") or "", value = attr("value") or "", } local idx = regInput(rec) local href if btype == "reset" then href = "__reset:" .. idx elseif btype == "button" then href = "__button:" .. idx else href = "__submit:" .. idx end push{ link = href, button = true } emitText("[ ") else emitText(" ]") pop() end elseif tname == "select" then -- упрощённо: elseif tname == "label" then -- inline (просто текст внутри) elseif tname == "center" then emit("parabr") if not isClose then push{ align = "center" } else pop(); emit("parabr") end elseif tname == "h1" or tname == "h2" or tname == "h3" or tname == "h4" or tname == "h5" or tname == "h6" then emit("parabr") if not isClose then local mods = { heading = tonumber(tname:sub(2)) or 1, bold = true } local al = attr("align") if al then mods.align = al:lower() end push(mods) else pop() emit("parabr") end elseif tname == "b" or tname == "strong" then if not isClose then push{ bold = true } else pop() end elseif tname == "i" or tname == "em" or tname == "cite" or tname == "var" or tname == "dfn" then if not isClose then push{ italic = true } else pop() end elseif tname == "u" or tname == "ins" then if not isClose then push{ underline = true } else pop() end elseif tname == "s" or tname == "del" or tname == "strike" then if not isClose then push{ strike = true } else pop() end elseif tname == "code" or tname == "tt" or tname == "kbd" or tname == "samp" then if not isClose then push{ code = true } else pop() end elseif tname == "pre" then emit("parabr") if not isClose then push{ preformat = true, code = true } else pop(); emit("parabr") end elseif tname == "blockquote" then emit("parabr") if not isClose then emit("quote_open") else emit("quote_close"); emit("parabr") end elseif tname == "mark" then if not isClose then push{ mark = true } else pop() end elseif tname == "small" or tname == "sub" or tname == "sup" then if not isClose then push{ small = true } else pop() end elseif tname == "a" then if not isClose then push{ link = attr("href") or "" } else pop() end elseif tname == "font" or tname == "span" then if not isClose then local mods = {} local col = attr("color") local st = attr("style") if col then local hex = col:match("^#?(%x%x%x%x%x%x)$") if hex then mods.fg = tonumber(hex, 16) end end if st then local hex = st:match("color%s*:%s*#(%x%x%x%x%x%x)") if hex then mods.fg = tonumber(hex, 16) end if st:find("font%-weight%s*:%s*bold") then mods.bold = true end if st:find("font%-style%s*:%s*italic") then mods.italic = true end end push(mods) else pop() end elseif tname == "ul" then emit("parabr") if not isClose then listTypes[#listTypes+1] = "ul" else listTypes[#listTypes] = nil; emit("parabr") end elseif tname == "ol" then emit("parabr") if not isClose then listTypes[#listTypes+1] = "ol" olCounters[#olCounters+1] = 0 else listTypes[#listTypes] = nil olCounters[#olCounters] = nil emit("parabr") end elseif tname == "li" then if not isClose then local lt = listTypes[#listTypes] or "ul" local marker if lt == "ol" then olCounters[#olCounters] = (olCounters[#olCounters] or 0) + 1 marker = tostring(olCounters[#olCounters]) .. ". " else marker = "• " end emit("li", { marker = marker, depth = #listTypes }) else emit("br") end elseif tname == "tr" then if not isClose then emit("br") end elseif tname == "td" or tname == "th" then if not isClose then emitText(" ") if tname == "th" then push{ bold = true } end else if tname == "th" then pop() end end elseif tname == "title" then if not isClose then push{ skip = true } else pop() end else -- неизвестный тег: игнорируем end end end return tokens, forms, inputs end -- ── раскладка токенов: word-wrap + инлайн-сегменты ──────────── local function _renderInputText(rec) local v = rec.value if (v == nil or v == "") and rec.placeholder ~= "" then v = rec.placeholder end v = v or "" if rec.type == "password" and rec.value and rec.value ~= "" then v = string.rep("*", unicode.len(rec.value)) end local sz = rec.size or 30 local vlen = unicode.len(v) if vlen > sz then v = unicode.sub(v, vlen - sz + 1) else v = v .. string.rep(" ", sz - vlen) end return "[" .. v .. "]" end local function layoutTokens(tokens, maxW, inputs) local lines = {} local links = {} local currentAlign = "left" local curSegs = {} local curWidth = 0 local lastWasBlank = true local pendingParaBreak = false local paragraphIndent = "" local pendingMarker = nil local quoteDepth = 0 local function styleToColors(st) local fg = st.fg or C.pageText local bg = st.bg or C.pageBg if st.code then fg = 0xf9e2af; bg = 0x313244 end if st.heading and st.heading > 0 then fg = C.heading end if st.link then fg = C.link end -- bold = ярко-оранжевый, явно отличается от основного текста if st.bold and not st.heading and not st.link and not st.code then fg = 0xfab387 end -- italic = бирюзовый if st.italic and not st.link and not st.code and not st.heading then fg = 0x94e2d5 end -- bold+italic = розовый if st.bold and st.italic and not st.link and not st.code and not st.heading then fg = 0xf38ba8 end if st.mark then bg = 0xf9e2af; fg = 0x1e1e2e end if st.small then fg = C.status end -- strike = тусклый if st.strike and not st.link and not st.code then fg = C.scrollFg end -- underline = выраженный фон if st.underline and bg == C.pageBg then bg = 0x585b70 end return fg, bg end local function pushSegRaw(text, fg, bg, linkIdx) if text == "" then return end local last = curSegs[#curSegs] if last and last.fg == fg and last.bg == bg and last.link == linkIdx then last.text = last.text .. text else curSegs[#curSegs+1] = { text = text, fg = fg, bg = bg, link = linkIdx } end curWidth = curWidth + unicode.len(text) end local function pushSeg(text, st, linkIdx) local fg, bg = styleToColors(st) pushSegRaw(text, fg, bg, linkIdx) end local function flushLine() if #curSegs == 0 and lastWasBlank then return end lines[#lines+1] = { segs = curSegs, align = currentAlign } lastWasBlank = (#curSegs == 0) curSegs = {} curWidth = 0 end local function ensurePrefix() if #curSegs == 0 then if quoteDepth > 0 then for _ = 1, quoteDepth do pushSegRaw("▌ ", C.scrollFg, C.pageBg, nil) end end if paragraphIndent ~= "" then pushSegRaw(paragraphIndent, C.pageText, C.pageBg, nil) end if pendingMarker then pushSegRaw(pendingMarker.text, pendingMarker.fg, C.pageBg, nil) pendingMarker = nil end end end local function addBlankLine() if lastWasBlank then return end lines[#lines+1] = { segs = {} } lastWasBlank = true end local function applyPara() if pendingParaBreak then if #curSegs > 0 then flushLine() end addBlankLine() pendingParaBreak = false end end local function addWord(word, st, linkIdx) if word == "" then return end applyPara() ensurePrefix() local wlen = unicode.len(word) if curWidth + wlen <= maxW then pushSeg(word, st, linkIdx); return end -- не помещается: перенос строки if curWidth > (paragraphIndent == "" and quoteDepth * 2 or unicode.len(paragraphIndent) + quoteDepth * 2) then flushLine(); ensurePrefix() end -- если слово длиннее доступной ширины — режем while unicode.len(word) > 0 and curWidth + unicode.len(word) > maxW do local room = math.max(1, maxW - curWidth) pushSeg(unicode.sub(word, 1, room), st, linkIdx) word = unicode.sub(word, room + 1) if unicode.len(word) > 0 then flushLine(); ensurePrefix() end end if unicode.len(word) > 0 then pushSeg(word, st, linkIdx) end end local function addSpace(st, linkIdx) applyPara() if #curSegs == 0 then return end if curWidth >= maxW then return end pushSeg(" ", st, linkIdx) end local function addTextToken(text, st) if st.align then currentAlign = st.align end local linkIdx = nil if st.link and st.link ~= "" then links[#links+1] = { href = st.link } linkIdx = #links end if st.preformat then applyPara() local s = text:gsub("\r\n", "\n"):gsub("\r", "\n") local first = true for line in (s .. "\n"):gmatch("([^\n]*)\n") do if not first then flushLine() end first = false ensurePrefix() local i, l = 1, unicode.len(line) while i <= l do local room = maxW - curWidth if room <= 0 then flushLine(); ensurePrefix(); room = maxW - curWidth end local chunk = unicode.sub(line, i, i + room - 1) pushSeg(chunk, st, linkIdx) i = i + unicode.len(chunk) end end return end text = text:gsub("[ \t\r\n]+", " ") if text == "" then return end if text == " " then addSpace(st, linkIdx); return end local startsWithSpace = text:sub(1,1) == " " local endsWithSpace = text:sub(-1) == " " if startsWithSpace then addSpace(st, linkIdx) end local trimmed = text:gsub("^ ", ""):gsub(" $", "") if trimmed ~= "" then local firstWord = true for word in trimmed:gmatch("[^ ]+") do if not firstWord then addSpace(st, linkIdx) end addWord(word, st, linkIdx) firstWord = false end end if endsWithSpace then addSpace(st, linkIdx) end end for _, tok in ipairs(tokens) do if tok.style and tok.style.skip then -- skip elseif tok.kind == "text" then addTextToken(tok.text, tok.style) elseif tok.kind == "br" then applyPara() flushLine() elseif tok.kind == "parabr" then pendingParaBreak = true paragraphIndent = "" pendingMarker = nil currentAlign = "left" elseif tok.kind == "hr" then applyPara() flushLine() lines[#lines+1] = { segs = { { text = string.rep("─", maxW), fg = C.scrollFg, bg = C.pageBg } } } lastWasBlank = false pendingParaBreak = true elseif tok.kind == "li" then applyPara() if #curSegs > 0 then flushLine() end paragraphIndent = string.rep(" ", math.max(0, (tok.depth or 1) - 1)) pendingMarker = { text = tok.marker or "• ", fg = C.scrollFg } elseif tok.kind == "quote_open" then quoteDepth = quoteDepth + 1 elseif tok.kind == "quote_close" then quoteDepth = math.max(0, quoteDepth - 1) elseif tok.kind == "img" then applyPara() if #curSegs > 0 then flushLine() end local cap = tok.alt if cap == "" then cap = tok.title or "" end if cap == "" then cap = tok.src ~= "" and tok.src or "image" end local label = "⌛ [IMG] " .. cap if unicode.len(label) > maxW then label = unicode.sub(label, 1, maxW - 1) .. "…" end ensurePrefix() local linkIdx = nil if tok.src and tok.src ~= "" then links[#links + 1] = { href = tok.src } linkIdx = #links end pushSegRaw(label, C.linkVisit, C.pageBg, linkIdx) flushLine() if tok.src and tok.src ~= "" then lines[#lines]._imgReq = { src = tok.src, alt = cap } end pendingParaBreak = true elseif tok.kind == "input_field" and inputs and inputs[tok.idx] then local rec = inputs[tok.idx] if tok.style and tok.style.align then currentAlign = tok.style.align end local label = _renderInputText(rec) applyPara() ensurePrefix() local need = unicode.len(label) if curWidth + need > maxW then flushLine(); ensurePrefix() end links[#links + 1] = { href = "__input:" .. tok.idx } local linkIdx = #links local seg = { text = label, fg = C.urlText, bg = C.urlBox, link = linkIdx, _inputIdx = tok.idx } curSegs[#curSegs + 1] = seg curWidth = curWidth + need rec._segRef = seg end end if #curSegs > 0 then flushLine() end -- посчитать первую строку каждой ссылки и lineRef для inputs for li, line in ipairs(lines) do for _, seg in ipairs(line.segs) do if seg.link then local lk = links[seg.link] if lk and not lk.firstLine then lk.firstLine = li end end if seg._inputIdx and inputs and inputs[seg._inputIdx] then local rec = inputs[seg._inputIdx] if not rec._lineRef then rec._lineRef = line end end end end -- Пост-pass: центрирование/выравнивание-вправо for _, line in ipairs(lines) do if line.align == "center" or line.align == "right" then local w = 0 for _, seg in ipairs(line.segs) do w = w + unicode.len(seg.text) end if w > 0 and w < maxW then local pad = (line.align == "center") and math.floor((maxW - w) / 2) or (maxW - w) if pad > 0 then table.insert(line.segs, 1, { text = string.rep(" ", pad), fg = C.pageText, bg = C.pageBg }) end end end end return lines, links end local function parseHTML(html, maxW) local tokens, forms, inputs = tokenizeHTML(html) local lines, links = layoutTokens(tokens, maxW, inputs) return lines, links, forms, inputs end -- ═══════════════════════════════════════════════════════════════ -- HTTP ЗАГРУЗКА -- ═══════════════════════════════════════════════════════════════ local function resolveUrl(base, href) if href:match("^https?://") then return href end if href:match("^//") then local scheme = base:match("^(https?:)") return (scheme or "http:") .. href end if href:match("^/") then local origin = base:match("^(https?://[^/]+)") return (origin or "") .. href end -- относительный local dir = base:match("^(.+/)") or base return dir .. href end -- ── Загрузка и рендер изображений через wsrv.nl + BMP ──────────── local IMG_FETCH_TIMEOUT = 10 local IMG_MAX_W_CHARS = 120 -- ограничение пикселей по горизонтали local IMG_MAX_H_PIXELS = 80 -- по вертикали (в полублоках = 40 строк) -- Цепочка прокси. Перебираются последовательно до первого успеха. -- url_pattern: %s = URL (опцион. urlEncoded); %d %d = maxW maxH (если resize). -- mode: "resize" (подставить W,H) или "raw" (только URL). local IMG_PROXIES = { { name = "wsrv", mode = "resize", encode = true, url = "https://wsrv.nl/?url=%s&w=%d&h=%d&fit=inside&output=png" }, { name = "codetabs", mode = "raw", encode = true, url = "https://api.codetabs.com/v1/proxy/?quest=%s" }, { name = "direct", mode = "raw", encode = false, url = "%s" }, } local function urlEncode(s) return (s:gsub("([^%w%-_.~])", function(c) return string.format("%%%02X", c:byte()) end)) end local function _readU(data, off, n) local v, mul = 0, 1 for i = 0, n - 1 do v = v + data:byte(off + i) * mul mul = mul * 256 end return v end local function fetchBytesSync(url, timeoutSec) local ok, req = pcall(function() return inet.request(url, nil, { ["User-Agent"] = "OCBrowser/2.1 (image)" }) end) if not ok or not req then return nil, "request failed" end local deadline = computer.uptime() + (timeoutSec or IMG_FETCH_TIMEOUT) while true do local fc, ferr = req.finishConnect() if fc then break end if ferr then pcall(req.close); return nil, "connect: " .. tostring(ferr) end if computer.uptime() > deadline then pcall(req.close); return nil, "connect timeout" end os.sleep(0.05) end local code, _, headers = req.response() if not code then pcall(req.close); return nil, "no response" end if code == 301 or code == 302 or code == 303 or code == 307 or code == 308 then local loc = headers and (headers["location"] or headers["Location"]) if type(loc) == "table" then loc = loc[1] end pcall(req.close) if loc and loc ~= "" then return fetchBytesSync(loc, timeoutSec) end return nil, "redirect no location" end if code >= 400 then pcall(req.close); return nil, "HTTP " .. tostring(code) end local chunks, total = {}, 0 while true do if computer.uptime() > deadline then pcall(req.close); return nil, "body timeout" end local chunk, rerr = req.read(8192) if rerr then pcall(req.close); return nil, "read: " .. tostring(rerr) end if not chunk then break end if #chunk > 0 then chunks[#chunks + 1] = chunk total = total + #chunk if total > 1024 * 1024 then pcall(req.close); return nil, "image too big" end else os.sleep(0.05) end end pcall(req.close) return table.concat(chunks) end local function parseBMP(data) if #data < 54 then return nil, "too small" end if data:sub(1, 2) ~= "BM" then return nil, "not BMP" end local pixOff = _readU(data, 11, 4) + 1 local w = _readU(data, 19, 4) local hr = _readU(data, 23, 4) local h, topDown = hr, false if h >= 0x80000000 then h = 0x100000000 - h; topDown = true end local bpp = _readU(data, 29, 2) local cmp = _readU(data, 31, 4) if bpp ~= 24 and bpp ~= 32 then return nil, "bpp " .. bpp end if cmp ~= 0 and not (bpp == 32 and cmp == 3) then return nil, "compression " .. cmp end if w == 0 or h == 0 or w > 400 or h > 400 then return nil, "size " .. w .. "x" .. h end local bytesPerPx = bpp / 8 local rowSize = math.floor((bpp * w + 31) / 32) * 4 local function pix(x, y) local row = topDown and y or (h - 1 - y) local off = pixOff + row * rowSize + x * bytesPerPx if off + 2 > #data then return 0, 0, 0 end local b = data:byte(off) local g = data:byte(off + 1) local r = data:byte(off + 2) return r, g, b end return { w = w, h = h, pix = pix } end -- ── PNG: DEFLATE inflate + расфильтровка скан-линий ──────────────── local function _inflate(data, startPos) -- data: строка байтов; startPos: 1-based local pos = startPos local bitBuf = 0 local bitCnt = 0 local out = {} local outLen = 0 local yieldCounter = 0 local function checkYield() yieldCounter = yieldCounter + 1 if yieldCounter >= 2000 then yieldCounter = 0 os.sleep(0) end end local function getBit() if bitCnt == 0 then if pos > #data then error("inflate: out of data") end bitBuf = data:byte(pos); pos = pos + 1; bitCnt = 8 end local b = bitBuf % 2 bitBuf = (bitBuf - b) / 2 bitCnt = bitCnt - 1 return b end local function getBits(n) local v, m = 0, 1 for _ = 1, n do v = v + getBit() * m m = m + m end return v end local function alignByte() bitCnt = 0; bitBuf = 0 end local function buildHuffman(lens) local maxLen = 0 for i = 1, #lens do if lens[i] > maxLen then maxLen = lens[i] end end local blCount = {} for i = 0, maxLen do blCount[i] = 0 end for i = 1, #lens do blCount[lens[i]] = blCount[lens[i]] + 1 end blCount[0] = 0 local nextCode = {} local code = 0 for bits = 1, maxLen do code = (code + (blCount[bits - 1] or 0)) * 2 nextCode[bits] = code end local tbl = {} for sym = 1, #lens do local l = lens[sym] if l > 0 then tbl[l] = tbl[l] or {} tbl[l][nextCode[l]] = sym - 1 nextCode[l] = nextCode[l] + 1 end end return { tbl = tbl, maxLen = maxLen } end local function decodeSym(huff) local code = 0 local tbl = huff.tbl for len = 1, huff.maxLen do code = code * 2 + getBit() local row = tbl[len] if row then local s = row[code] if s ~= nil then return s end end end error("inflate: bad huffman") end local lenBase = {3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258} local lenExtra = {0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0} local distBase = {1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577} local distExtra = {0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13} local fixedLit, fixedDist local function getFixed() if fixedLit then return fixedLit, fixedDist end local ll = {} for i = 1, 144 do ll[i] = 8 end for i = 145,256 do ll[i] = 9 end for i = 257,280 do ll[i] = 7 end for i = 281,288 do ll[i] = 8 end local dl = {} for i = 1, 32 do dl[i] = 5 end fixedLit = buildHuffman(ll) fixedDist = buildHuffman(dl) return fixedLit, fixedDist end while true do local bfinal = getBit() local btype = getBits(2) if btype == 0 then alignByte() if pos + 3 > #data then error("inflate: stored too short") end local len = data:byte(pos) + data:byte(pos + 1) * 256 pos = pos + 4 for _ = 1, len do outLen = outLen + 1 out[outLen] = data:byte(pos) pos = pos + 1 end elseif btype == 1 or btype == 2 then local litH, distH if btype == 1 then litH, distH = getFixed() else local hlit = getBits(5) + 257 local hdist = getBits(5) + 1 local hclen = getBits(4) + 4 local order = {16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15} local clens = {} for i = 1, 19 do clens[i] = 0 end for i = 1, hclen do clens[order[i] + 1] = getBits(3) end local clH = buildHuffman(clens) local lens = {} local i = 1 local total = hlit + hdist while i <= total do local s = decodeSym(clH) if s < 16 then lens[i] = s; i = i + 1 elseif s == 16 then local rep = getBits(2) + 3 local prev = lens[i - 1] or 0 for _ = 1, rep do lens[i] = prev; i = i + 1 end elseif s == 17 then local rep = getBits(3) + 3 for _ = 1, rep do lens[i] = 0; i = i + 1 end else -- 18 local rep = getBits(7) + 11 for _ = 1, rep do lens[i] = 0; i = i + 1 end end end local ll = {}; for k = 1, hlit do ll[k] = lens[k] end local dl = {}; for k = 1, hdist do dl[k] = lens[hlit + k] end litH = buildHuffman(ll) distH = buildHuffman(dl) end while true do checkYield() local s = decodeSym(litH) if s < 256 then outLen = outLen + 1 out[outLen] = s elseif s == 256 then break else local lc = s - 257 + 1 local len = lenBase[lc] + getBits(lenExtra[lc]) local ds = decodeSym(distH) + 1 local dist = distBase[ds] + getBits(distExtra[ds]) local startIdx = outLen - dist + 1 for k = 0, len - 1 do outLen = outLen + 1 out[outLen] = out[startIdx + k] end end end else error("inflate: btype 3") end if bfinal == 1 then break end end return out end local function parsePNG(data) if #data < 8 or data:sub(1, 8) ~= "\137PNG\r\n\26\n" then return nil, "not PNG" end local function readBE(off, n) local v = 0 for i = 0, n - 1 do v = v * 256 + data:byte(off + i) end return v end local pos = 9 local width, height, bitDepth, colorType local palette = nil local idat = {} while pos <= #data do if pos + 7 > #data then break end local len = readBE(pos, 4) local typ = data:sub(pos + 4, pos + 7) local dataStart = pos + 8 pos = pos + 8 + len + 4 -- + crc if typ == "IHDR" then width = readBE(dataStart, 4) height = readBE(dataStart + 4, 4) bitDepth = data:byte(dataStart + 8) colorType = data:byte(dataStart + 9) elseif typ == "PLTE" then palette = {} for i = 0, math.floor(len / 3) - 1 do palette[i] = { data:byte(dataStart + i * 3), data:byte(dataStart + i * 3 + 1), data:byte(dataStart + i * 3 + 2), } end elseif typ == "IDAT" then idat[#idat + 1] = data:sub(dataStart, dataStart + len - 1) elseif typ == "IEND" then break end end if not width or not height then return nil, "no IHDR" end if bitDepth ~= 8 then return nil, "bitdepth " .. bitDepth end if colorType ~= 0 and colorType ~= 2 and colorType ~= 3 and colorType ~= 4 and colorType ~= 6 then return nil, "colortype " .. colorType end if colorType == 3 and not palette then return nil, "no PLTE" end local zdata = table.concat(idat) if #zdata < 3 then return nil, "empty IDAT" end -- zlib header: 2 bytes; trailer adler32: 4 bytes (игнорируем) local ok, raw = pcall(_inflate, zdata, 3) if not ok then return nil, "inflate: " .. tostring(raw) end local channels = ({[0]=1, [2]=3, [3]=1, [4]=2, [6]=4})[colorType] local bpp = channels -- байт на пиксель (при 8-bit) local stride = width * bpp local rgb = {} -- плоский массив, 3 байта на пиксель (R,G,B) local prev = {} for i = 1, stride do prev[i] = 0 end local idx = 1 for y = 0, height - 1 do if idx > #raw then return nil, "truncated raw" end local ftype = raw[idx]; idx = idx + 1 local row = {} for x = 1, stride do local r = raw[idx]; idx = idx + 1 if not r then return nil, "truncated row" end local left = (x > bpp) and row[x - bpp] or 0 local up = prev[x] local upLeft = (x > bpp) and prev[x - bpp] or 0 local rec if ftype == 0 then rec = r elseif ftype == 1 then rec = (r + left) % 256 elseif ftype == 2 then rec = (r + up) % 256 elseif ftype == 3 then rec = (r + math.floor((left + up) / 2)) % 256 elseif ftype == 4 then local p = left + up - upLeft local pa = math.abs(p - left) local pb = math.abs(p - up) local pc = math.abs(p - upLeft) local pred if pa <= pb and pa <= pc then pred = left elseif pb <= pc then pred = up else pred = upLeft end rec = (r + pred) % 256 else return nil, "filter " .. ftype end row[x] = rec end -- извлечь RGB из строки local rowBase = y * width * 3 for x = 0, width - 1 do local r, g, b if colorType == 2 then -- RGB local off = x * 3 r, g, b = row[off + 1], row[off + 2], row[off + 3] elseif colorType == 6 then -- RGBA local off = x * 4 r, g, b = row[off + 1], row[off + 2], row[off + 3] elseif colorType == 0 then -- Gray local v = row[x + 1]; r, g, b = v, v, v elseif colorType == 4 then -- GrayA local v = row[x * 2 + 1]; r, g, b = v, v, v elseif colorType == 3 then -- Palette local pi = row[x + 1] local pe = palette[pi] if pe then r, g, b = pe[1], pe[2], pe[3] else r, g, b = 0, 0, 0 end end rgb[rowBase + x * 3 + 1] = r rgb[rowBase + x * 3 + 2] = g rgb[rowBase + x * 3 + 3] = b end prev = row if y % 8 == 0 then os.sleep(0) end end local function pix(x, y) local off = (y * width + x) * 3 + 1 return rgb[off] or 0, rgb[off + 1] or 0, rgb[off + 2] or 0 end return { w = width, h = height, pix = pix } end -- ── выбор парсера по сигнатуре ───────────────────────────────── local function decodeImage(data) if #data < 8 then return nil, "too small" end if data:sub(1, 2) == "BM" then return parseBMP(data) end if data:sub(1, 8) == "\137PNG\r\n\26\n" then return parsePNG(data) end return nil, "unknown format (" .. tostring(data:byte(1)) .. " " .. tostring(data:byte(2)) .. ")" end -- ── Кеш изображений: память (LRU отрендеренных lines) + диск (RGB) ── local IMG_CACHE_DIR = "/home/.cache/ocbrowser/img/" local IMG_CACHE_BUDGET = 512 * 1024 -- байт на диске; настраивается local IMG_MEM_CACHE_CAP = 8 -- сколько lines держать в RAM -- djb2 hash → hex local function _hash(s) local h = 5381 for i = 1, #s do h = ((h * 33) + s:byte(i)) % 2147483647 end return string.format("%08x", h) end local function _cacheKey(src, w, h) return _hash(src .. "|" .. w .. "x" .. h) end local function _cachePath(key) return IMG_CACHE_DIR .. key .. ".rgb" end local function _ensureCacheDir() if not fs then return false end if fs.exists(IMG_CACHE_DIR) then return true end local ok = pcall(fs.makeDirectory, IMG_CACHE_DIR) return ok and fs.exists(IMG_CACHE_DIR) end local function _writeU32BE(f, v) f:write(string.char( math.floor(v / 16777216) % 256, math.floor(v / 65536) % 256, math.floor(v / 256) % 256, v % 256)) end local function _readU32BE(f) local s = f:read(4) if not s or #s < 4 then return nil end return s:byte(1) * 16777216 + s:byte(2) * 65536 + s:byte(3) * 256 + s:byte(4) end local function diskCacheLoad(key) if not fs then return nil end local path = _cachePath(key) if not fs.exists(path) then return nil end local f = io.open(path, "rb") if not f then return nil end local w = _readU32BE(f) local h = _readU32BE(f) if not w or not h or w == 0 or h == 0 or w > 1000 or h > 1000 then f:close(); return nil end local need = w * h * 3 local data = f:read(need) f:close() if not data or #data < need then return nil end local function pix(x, y) local off = (y * w + x) * 3 + 1 return data:byte(off) or 0, data:byte(off + 1) or 0, data:byte(off + 2) or 0 end return { w = w, h = h, pix = pix } end local function _diskTotalAndList() local files, total = {}, 0 if not fs or not fs.exists(IMG_CACHE_DIR) then return files, 0 end for name in fs.list(IMG_CACHE_DIR) do if not name:match("/$") then local p = IMG_CACHE_DIR .. name local sz = fs.size(p) or 0 local mt = fs.lastModified(p) or 0 files[#files + 1] = { path = p, size = sz, mtime = mt } total = total + sz end end return files, total end local function _evictDisk(reserve) local files, total = _diskTotalAndList() local cap = IMG_CACHE_BUDGET - (reserve or 0) if total <= cap then return end table.sort(files, function(a, b) return a.mtime < b.mtime end) local i = 1 while total > cap * 0.75 and i <= #files do if pcall(fs.remove, files[i].path) then total = total - files[i].size end i = i + 1 end end local function diskCacheSave(key, img) if not fs then return end if not _ensureCacheDir() then return end local need = img.w * img.h * 3 + 8 if need > IMG_CACHE_BUDGET then return end -- картинка больше всего бюджета _evictDisk(need) local path = _cachePath(key) local ok, f = pcall(io.open, path, "wb") if not ok or not f then return end _writeU32BE(f, img.w) _writeU32BE(f, img.h) local chunks, n = {}, 0 for y = 0, img.h - 1 do for x = 0, img.w - 1 do local r, g, b = img.pix(x, y) n = n + 1 chunks[n] = string.char(r or 0, g or 0, b or 0) end if n >= 512 then f:write(table.concat(chunks, "", 1, n)) chunks, n = {}, 0 if y % 16 == 0 then os.sleep(0) end end end if n > 0 then f:write(table.concat(chunks, "", 1, n)) end f:close() end -- ── Mem LRU: ключ → отрендеренные lines (общий с диск-уровнем ключ) ── local _memCache = { items = {}, order = {} } local function memCacheGet(key) local v = _memCache.items[key] if not v then return nil end for i, k in ipairs(_memCache.order) do if k == key then table.remove(_memCache.order, i); break end end _memCache.order[#_memCache.order + 1] = key return v end local function memCachePut(key, lines) if _memCache.items[key] then for i, k in ipairs(_memCache.order) do if k == key then table.remove(_memCache.order, i); break end end end _memCache.items[key] = lines _memCache.order[#_memCache.order + 1] = key while #_memCache.order > IMG_MEM_CACHE_CAP do local old = table.remove(_memCache.order, 1) _memCache.items[old] = nil end end -- глубокая копия списка lines (чтобы splice/перерисовка не портили кеш) local function _cloneLines(src) local out = {} for i, ln in ipairs(src) do local segs = {} for j, s in ipairs(ln.segs) do segs[j] = { text = s.text, fg = s.fg, bg = s.bg, link = s.link } end out[i] = { segs = segs } end return out end local function renderImageToLines(img) local out = {} local rows = math.ceil(img.h / 2) for cy = 0, rows - 1 do local segs = {} local lastFg, lastBg = -1, -1 for x = 0, img.w - 1 do local r1, g1, b1 = img.pix(x, cy * 2) local fg = r1 * 65536 + g1 * 256 + b1 local bg if cy * 2 + 1 < img.h then local r2, g2, b2 = img.pix(x, cy * 2 + 1) bg = r2 * 65536 + g2 * 256 + b2 else bg = C.pageBg end if fg == lastFg and bg == lastBg and #segs > 0 then segs[#segs].text = segs[#segs].text .. "▀" else segs[#segs + 1] = { text = "▀", fg = fg, bg = bg, link = nil } lastFg, lastBg = fg, bg end end out[#out + 1] = { segs = segs } end return out end -- пересчёт firstLine у всех ссылок (вызывается после сплайса) local function recomputeLinkLines() for _, lk in ipairs(state.links) do lk.firstLine = nil end for li, ln in ipairs(state.lines) do if ln.segs then for _, seg in ipairs(ln.segs) do if seg.link then local lk = state.links[seg.link] if lk and not lk.firstLine then lk.firstLine = li end end end end end end function processNextImage() if not state.imgQueue or #state.imgQueue == 0 then return end local req = table.remove(state.imgQueue, 1) -- найти текущий индекс плейсхолдера по ссылке на таблицу строки local L = nil for i, ln in ipairs(state.lines) do if ln == req.lineRef then L = i; break end end if not L then return end local prevStatus = state.statusMsg state.statusMsg = "Изображение: " .. (req.alt ~= "" and req.alt or req.src) drawBar() local absSrc = req.src if not absSrc:match("^https?://") then absSrc = resolveUrl(state.url, absSrc) end local maxW = math.min(IMG_MAX_W_CHARS, PAGE_W - 1) local maxH = IMG_MAX_H_PIXELS local key = _cacheKey(absSrc, maxW, maxH) local newLines local img -- 1) mem hit → мгновенно local cachedLines = memCacheGet(key) if cachedLines then newLines = _cloneLines(cachedLines) else -- 2) disk hit → декодировать RGB и отрендерить img = diskCacheLoad(key) if img then state.statusMsg = "Изображение [cache]: " .. (req.alt ~= "" and req.alt or req.src) drawBar() else -- 3) miss → перебор прокси local errors = {} for _, p in ipairs(IMG_PROXIES) do state.statusMsg = "Изображение [" .. p.name .. "]: " .. (req.alt ~= "" and req.alt or req.src) drawBar() local src = p.encode and urlEncode(absSrc) or absSrc local url if p.mode == "resize" then url = string.format(p.url, src, maxW, maxH) else url = string.format(p.url, src) end local body, ferr = fetchBytesSync(url, IMG_FETCH_TIMEOUT) if body then local decoded, derr = decodeImage(body) if decoded then img = decoded break else errors[#errors + 1] = p.name .. ":декод:" .. tostring(derr) end else errors[#errors + 1] = p.name .. ":" .. tostring(ferr) end end if img then pcall(diskCacheSave, key, img) else newLines = { { segs = { { text = "✖ [IMG] " .. (req.alt or "") .. " — " .. table.concat(errors, " | "), fg = C.error, bg = C.pageBg, link = nil } } } } end end if img and not newLines then newLines = renderImageToLines(img) if req.alt and req.alt ~= "" then newLines[#newLines + 1] = { segs = { { text = " " .. req.alt, fg = C.status, bg = C.pageBg, link = nil } } } end -- сохранить в mem-кеш (отдельной копией, см. _cloneLines на чтении) memCachePut(key, _cloneLines(newLines)) end end -- сплайс: удалить плейсхолдер, вставить строки изображения table.remove(state.lines, L) for i, ln in ipairs(newLines) do table.insert(state.lines, L + i - 1, ln) end recomputeLinkLines() state.statusMsg = prevStatus drawBar() drawPage() end local function setLoading(on) state.loading = on and true or false if not on then state.loadProgress = nil state.loadReceived = 0 state.loadTotal = 0 end end local UA_BROWSER = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " .. "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OCBrowser/2.1" local function startFetchJob(url, postData, method) if state.fetchJob and state.fetchJob.req and state.fetchJob.req.close then pcall(function() state.fetchJob.req.close() end) end setLoading(true) state.statusMsg = "Загрузка: " .. url state.loadProgress = nil state.loadReceived = 0 state.loadTotal = 0 state.lines = { { text = "Загрузка...", color = C.status } } state.links = {} state.scroll = 0 state.selLink = -1 local headers = { ["User-Agent"] = UA_BROWSER, ["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain,*/*;q=0.8", ["Accept-Language"] = "en-US,en;q=0.9,ru;q=0.8", } if postData then headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8" headers["Content-Length"] = tostring(#postData) end state.fetchJob = { url = url, phase = "connect", req = inet.request(url, postData, headers, method or (postData and "POST" or "GET")), chunks = {}, received = 0, total = nil, ct = nil, code = nil, err = nil, redirectCount = 0, postData = postData, method = method, } drawBar(); drawPage() end local function finalizeFetch(job) state.fetchJob = nil setLoading(false) if job.err then state.statusMsg = "Ошибка: " .. tostring(job.err) local errHtml = string.format([[ <html><body> <h1>Ошибка загрузки</h1> <p>URL: %s</p> <p>%s</p> <p><a href="about:home">На главную</a></p> </body></html>]], job.url, tostring(job.err)) renderPage(errHtml, "text/html") else local body = table.concat(job.chunks) state.statusMsg = (job.code and ("HTTP " .. job.code .. " | ") or "") .. job.url renderPage(body, job.ct) end drawBar(); drawPage() end local function stepFetchJob() local job = state.fetchJob if not job then return end if not job.req then job.err = job.err or "Нет запроса"; return finalizeFetch(job) end local ok, stepErr = pcall(function() if job.phase == "connect" then if job.req.finishConnect() then job.phase = "headers" end return end if job.phase == "headers" then local respCode, respMsg, headers = job.req.response() if not respCode then job.req.close() error("Пустой ответ") end job.code = respCode if job.code == 301 or job.code == 302 or job.code == 303 or job.code == 307 or job.code == 308 then local loc = headers and (headers["location"] or headers["Location"]) if loc then if type(loc) == "table" then loc = loc[1] end job.req.close() job.redirectCount = (job.redirectCount or 0) + 1 if job.redirectCount > 10 then error("Слишком много редиректов") end local newUrl = resolveUrl(job.url, loc) state.url = newUrl startFetchJob(newUrl) return end end if job.code >= 400 then job.req.close() error("HTTP " .. job.code .. (respMsg and (" " .. respMsg) or "")) end local rawCt = headers and (headers["content-type"] or headers["Content-Type"] or "text/html") or "text/html" if type(rawCt) == "table" then rawCt = rawCt[1] end job.ct = rawCt local cl = headers and (headers["content-length"] or headers["Content-Length"]) or nil if type(cl) == "table" then cl = cl[1] end if cl then local n = tonumber(cl) if n and n > 0 then job.total = n end end job.phase = "body" return end if job.phase == "body" then local chunk = job.req.read(4096) if chunk then job.chunks[#job.chunks + 1] = chunk job.received = (job.received or 0) + #chunk state.loadReceived = job.received state.loadTotal = job.total or 0 if job.total then state.loadProgress = math.max(0, math.min(1, job.received / job.total)) else state.loadProgress = nil end state.statusMsg = "Загрузка: " .. job.url drawBar() else job.req.close() job.phase = "done" finalizeFetch(job) end return end end) if not ok then job.err = tostring(stepErr) pcall(function() if job.req and job.req.close then job.req.close() end end) finalizeFetch(job) end end local function fetchUrl(url) state.loading = true state.statusMsg = "Подключение к " .. url .. "..." drawBar() local ok, resp = pcall(function() local req = inet.request(url, nil, { ["User-Agent"] = "OCBrowser/2.0 OpenComputers", ["Accept"] = "text/html,text/plain,*/*", }) -- ждём ответа до 10 сек local deadline = os.time() + 100 -- 100 тиков ≈ 5 сек while not req.finishConnect() do if os.time() > deadline then error("Таймаут соединения") end os.sleep(0.05) end local code, msg, headers = req.response() if not code then error("Нет ответа от сервера") end -- обработка редиректов (301,302,303,307,308) if (code == 301 or code == 302 or code == 303 or code == 307 or code == 308) then local loc = (headers and (headers["location"] or headers["Location"])) or nil if loc then req.close() if type(loc) == "table" then loc = loc[1] end return fetchUrl(resolveUrl(url, loc)) end end if code >= 400 then error("HTTP " .. code .. " " .. (msg or "")) end -- читаем тело local body = "" local chunk repeat chunk = req.read(8192) if chunk then body = body .. chunk end until not chunk req.close() -- определяем Content-Type local ct = "" if headers then ct = headers["content-type"] or headers["Content-Type"] or "" end if type(ct) == "table" then ct = ct[1] end return body, ct, code end) state.loading = false if not ok then return nil, tostring(resp) end return resp -- body, ct, code через pcall-vararg не идеально, перепишем ниже end -- Исправленная версия с явными возвратами local function doFetch(url) state.loading = true state.statusMsg = "Загрузка: " .. url drawBar() local body, err, ct, code local ok, e = pcall(function() local req = inet.request(url, nil, { ["User-Agent"] = "OCBrowser/2.0 (OpenComputers)", ["Accept"] = "text/html,text/plain,*/*;q=0.9", }) local deadline = computer and (computer.uptime() + 10) or nil while not req.finishConnect() do if deadline and computer.uptime() > deadline then req.close() error("Таймаут соединения") end os.sleep(0.05) end local respCode, respMsg, headers = req.response() if not respCode then req.close(); error("Пустой ответ") end code = respCode -- редирект if code == 301 or code == 302 or code == 303 or code == 307 or code == 308 then local loc = headers and (headers["location"] or headers["Location"]) if loc then if type(loc) == "table" then loc = loc[1] end req.close() local rb, re, rct, rc = doFetch(resolveUrl(url, loc)) body = rb; err = re; ct = rct; code = rc return end end if code >= 400 then req.close() error("HTTP " .. code .. (respMsg and (" " .. respMsg) or "")) end local rawCt = headers and (headers["content-type"] or headers["Content-Type"] or "text/html") or "text/html" if type(rawCt) == "table" then rawCt = rawCt[1] end ct = rawCt local chunks = {} local chunk repeat chunk = req.read(4096) if chunk then chunks[#chunks + 1] = chunk end until not chunk req.close() body = table.concat(chunks) end) state.loading = false if not ok then return nil, tostring(e), nil, nil end return body, err, ct, code end -- ═══════════════════════════════════════════════════════════════ -- ОБЛАСТЬ СТРАНИЦЫ -- ═══════════════════════════════════════════════════════════════ -- отрисовка одной строки области страницы (row: 1..PAGE_H) local function drawRow(row) local li = state.scroll + row local y = PAGE_Y + row - 1 local line = state.lines[li] if not line or not line.segs then setBg(C.pageBg) gpu.fill(1, y, PAGE_W, 1, " ") return end local selIdx = state.selLink local x = 1 local lastFg, lastBg = nil, nil for _, seg in ipairs(line.segs) do local fg = seg.fg or C.pageText local bg = seg.bg or C.pageBg if selIdx >= 1 and seg.link == selIdx then bg = C.btnHover fg = C.btnFg end if fg ~= lastFg then setFg(fg); lastFg = fg end if bg ~= lastBg then setBg(bg); lastBg = bg end gpu.set(x, y, seg.text) x = x + unicode.len(seg.text) if x > PAGE_W then break end end if x <= PAGE_W then if lastBg ~= C.pageBg then setBg(C.pageBg) end gpu.fill(x, y, PAGE_W - x + 1, 1, " ") end end local function drawScrollbar() local total = #state.lines local scroll = state.scroll setBg(C.scrollbar) setFg(C.scrollFg) fill(W, PAGE_Y, 1, PAGE_H, "│") if total > PAGE_H then local barH = math.max(1, math.floor(PAGE_H * PAGE_H / total)) local barY = math.floor(scroll * (PAGE_H - barH) / math.max(1, total - PAGE_H)) setBg(C.scrollFg) fill(W, PAGE_Y + barY, 1, barH, "█") end end drawPage = function() for row = 1, PAGE_H do drawRow(row) end drawScrollbar() end -- инкрементальный скролл: сдвиг через gpu.copy + рендер только новых N строк local function drawPageScroll(oldScroll, newScroll) local delta = newScroll - oldScroll if delta == 0 then return end if math.abs(delta) >= PAGE_H then drawPage() return end if delta > 0 then -- контент сдвигается вверх на delta; новые строки внизу gpu.copy(1, PAGE_Y + delta, PAGE_W, PAGE_H - delta, 0, -delta) for row = PAGE_H - delta + 1, PAGE_H do drawRow(row) end else local n = -delta gpu.copy(1, PAGE_Y, PAGE_W, PAGE_H - n, 0, n) for row = 1, n do drawRow(row) end end drawScrollbar() end -- ═══════════════════════════════════════════════════════════════ -- ЗАГРУЗКА И РЕНДЕР СТРАНИЦЫ -- ═══════════════════════════════════════════════════════════════ renderPage = function(body, ct) ct = ct or "text/html" local isHtml = ct:find("html") or body:match("^%s*<!?[Hh][Tt][Mm][Ll]") or body:match("<[%a/][%w]*[%s/>]") local lines, links, forms, inputs if isHtml then lines, links, forms, inputs = parseHTML(body, PAGE_W) else lines = {} links = {} forms = {} inputs = {} local s = body:gsub("\r\n", "\n"):gsub("\r", "\n") for line in (s .. "\n"):gmatch("([^\n]*)\n") do local rest = line if rest == "" then lines[#lines+1] = { segs = {} } else while unicode.len(rest) > PAGE_W do lines[#lines+1] = { segs = { { text = unicode.sub(rest, 1, PAGE_W), fg = C.pageText, bg = C.pageBg } } } rest = unicode.sub(rest, PAGE_W + 1) end lines[#lines+1] = { segs = { { text = rest, fg = C.pageText, bg = C.pageBg } } } end end end state.lines = lines state.links = links state.forms = forms or {} state.inputs = inputs or {} state.scroll = 0 state.selLink = -1 state.inputEdit = nil -- Авто-детект «JS-only» страницы: если HTML был большой но видимого текста почти нет — -- подсказать пользователю про Reader Mode (Ctrl+R). if isHtml and not state.url:find("r.jina.ai", 1, true) then local visibleChars = 0 for _, ln in ipairs(lines) do for _, seg in ipairs(ln.segs or {}) do local t = seg.text or "" if not t:match("^%s*$") then visibleChars = visibleChars + unicode.len(t) end end if visibleChars > 200 then break end end if visibleChars < 100 and #body > 800 then state.statusMsg = "Страница почти пустая (JS-only?). Нажмите Ctrl+R — Reader Mode" end end -- собрать очередь изображений (плейсхолдеры помечены ._imgReq) state.imgQueue = {} for _, ln in ipairs(state.lines) do if ln._imgReq then state.imgQueue[#state.imgQueue + 1] = { lineRef = ln, src = ln._imgReq.src, alt = ln._imgReq.alt } ln._imgReq = nil end end end local function _formUrlEncode(s) s = tostring(s or "") return (s:gsub("([^%w%-_.~])", function(c) if c == " " then return "+" end return string.format("%%%02X", c:byte()) end)) end local function buildFormQuery(formIdx) local f = state.forms and state.forms[formIdx] if not f then return "" end local parts = {} for _, ii in ipairs(f.inputs) do local rec = state.inputs[ii] if rec and rec.name and rec.name ~= "" then if rec.type == "checkbox" or rec.type == "radio" then if rec.checked then parts[#parts + 1] = _formUrlEncode(rec.name) .. "=" .. _formUrlEncode(rec.value or "on") end elseif rec.type == "submit" or rec.type == "image" or rec.type == "button" or rec.type == "reset" then -- submit-кнопка отправляется только если это та, на которую нажали (учтено вызывающей стороной) else parts[#parts + 1] = _formUrlEncode(rec.name) .. "=" .. _formUrlEncode(rec.value or "") end end end return table.concat(parts, "&") end -- Reader Mode: перезапрашивает текущий URL через r.jina.ai (JS-render на их сервере → Markdown) local openInReader local function navigate(url, pushHistory, postData) if url == "" then return end -- нормализация if not url:match("^about:") and not url:match("^https?://") then url = "http://" .. url end if url == "about:home" then state.url = url state.statusMsg = "Домашняя страница" renderPage(HOME_PAGE, "text/html") if state.fetchJob then if state.fetchJob.req and state.fetchJob.req.close then pcall(function() state.fetchJob.req.close() end) end state.fetchJob = nil setLoading(false) end if pushHistory ~= false then if state.histPos < #state.history then for i = state.histPos + 1, #state.history do state.history[i] = nil end end state.history[#state.history + 1] = url state.histPos = #state.history end drawBar(); drawPage() return end if pushHistory ~= false then if state.histPos < #state.history then for i = state.histPos + 1, #state.history do state.history[i] = nil end end state.history[#state.history + 1] = url state.histPos = #state.history end state.url = url startFetchJob(url, postData, postData and "POST" or "GET") end openInReader = function(targetUrl) targetUrl = targetUrl or state.url if not targetUrl or targetUrl == "" or targetUrl:match("^about:") then state.statusMsg = "Reader Mode: нет URL для загрузки" drawBar() return end if targetUrl:find("r.jina.ai", 1, true) then state.statusMsg = "Уже в Reader Mode" drawBar() return end navigate("https://r.jina.ai/" .. targetUrl) end -- Сабмит формы по индексу submit-кнопки внутри inputs[] local function submitForm(submitIdx) local rec = state.inputs[submitIdx] if not rec or not rec.formIdx then state.statusMsg = "Кнопка вне формы" drawBar() return end local f = state.forms[rec.formIdx] if not f then return end local query = buildFormQuery(rec.formIdx) -- если у submit-кнопки есть name= — добавить её if rec.name and rec.name ~= "" then local extra = _formUrlEncode(rec.name) .. "=" .. _formUrlEncode(rec.value or "") query = (query ~= "" and (query .. "&") or "") .. extra end local action = f.action ~= "" and f.action or state.url action = resolveUrl(state.url, action) if f.method == "post" then navigate(action, true, query) else -- GET: добавить query к URL if query ~= "" then local sep = action:find("?", 1, true) and "&" or "?" action = action .. sep .. query end navigate(action) end end -- Перерисовать строку с inputом после изменения значения local function refreshInputDisplay(idx) local rec = state.inputs[idx] if not rec or not rec._segRef or not rec._lineRef then return end rec._segRef.text = _renderInputText(rec) -- найти номер строки for li, ln in ipairs(state.lines) do if ln == rec._lineRef then local row = li - state.scroll if row >= 1 and row <= PAGE_H then drawRow(row) end break end end end -- ═══════════════════════════════════════════════════════════════ -- НАВИГАЦИЯ СКРОЛЛ / ССЫЛКИ -- ═══════════════════════════════════════════════════════════════ local function scrollBy(n) local oldScroll = state.scroll local maxScroll = math.max(0, #state.lines - PAGE_H) local newScroll = math.max(0, math.min(maxScroll, oldScroll + n)) if newScroll == oldScroll then return end state.scroll = newScroll drawPageScroll(oldScroll, newScroll) end local function goBack() if state.histPos > 1 then state.histPos = state.histPos - 1 navigate(state.history[state.histPos], false) end end local function goForward() if state.histPos < #state.history then state.histPos = state.histPos + 1 navigate(state.history[state.histPos], false) end end local function selectNextLink(dir) dir = dir or 1 if #state.links == 0 then return end state.selLink = state.selLink + dir if state.selLink < 1 then state.selLink = #state.links end if state.selLink > #state.links then state.selLink = 1 end local lk = state.links[state.selLink] local li = lk and lk.firstLine or 1 if li - state.scroll < 1 then state.scroll = li - 1 end if li - state.scroll > PAGE_H then state.scroll = li - PAGE_H end state.scroll = math.max(0, state.scroll) drawPage() end local function startInputEdit(idx) local rec = state.inputs[idx] if not rec then return end state.inputEdit = { idx = idx, buf = rec.value or "", cursor = unicode.len(rec.value or ""), } state.urlFocus = false drawBar() end -- разбор синтетических ssylok form: __input:N / __submit:N / __reset:N / __button:N local function dispatchLink(href) if href == "" then return false end local kind, idx = href:match("^__(%w+):(%d+)$") if kind and idx then idx = tonumber(idx) if kind == "input" then startInputEdit(idx) return true elseif kind == "submit" then submitForm(idx) return true elseif kind == "reset" then local rec = state.inputs[idx] if rec and rec.formIdx then local f = state.forms[rec.formIdx] for _, ii in ipairs(f.inputs) do local r = state.inputs[ii] if r and r.type ~= "submit" and r.type ~= "button" and r.type ~= "reset" then r.value = "" refreshInputDisplay(ii) end end end return true elseif kind == "button" then state.statusMsg = "<button> без type=submit — нет JS-обработчика" drawBar() return true end end return false end local function openSelectedLink() if state.selLink >= 1 and state.links[state.selLink] then local href = state.links[state.selLink].href or "" if href == "" then return end if dispatchLink(href) then return end navigate(resolveUrl(state.url, href)) end end -- ═══════════════════════════════════════════════════════════════ -- ОБРАБОТКА КЛИКОВ -- ═══════════════════════════════════════════════════════════════ local function handleClick(x, y, btn) -- кнопки навигации if y == 2 then if x >= BTN[1].x and x < BTN[1].x + 3 then goBack() elseif x >= BTN[2].x and x < BTN[2].x + 3 then goForward() elseif x >= BTN[3].x and x < BTN[3].x + 3 then navigate(state.url, false) elseif x >= BTN[4].x and x < BTN[4].x + 3 then navigate("about:home") elseif x >= URL_X and x < URL_X + URL_W then state.urlFocus = true state.urlBar = state.url state.urlCursor = unicode.len(state.urlBar) drawBar() elseif x >= GO_X then if state.urlFocus then navigate(state.urlBar) state.urlFocus = false end else state.urlFocus = false drawBar() end return end -- область страницы — переход по ссылке if y >= PAGE_Y and y < PAGE_Y + PAGE_H then local row = y - PAGE_Y + 1 local li = state.scroll + row local line = state.lines[li] if line and line.segs then local col = 1 for _, seg in ipairs(line.segs) do local sw = unicode.len(seg.text) if x >= col and x < col + sw then if seg.link then local lk = state.links[seg.link] if lk and lk.href and lk.href ~= "" then state.selLink = seg.link if dispatchLink(lk.href) then return end navigate(resolveUrl(state.url, lk.href)) return end end break end col = col + sw end end end end -- ═══════════════════════════════════════════════════════════════ -- ОБРАБОТКА КЛАВИАТУРЫ -- ═══════════════════════════════════════════════════════════════ local function handleKey(keycode, char, player) -- Режим редактирования инпута формы if state.inputEdit then local ie = state.inputEdit if keycode == keyboard.keys.enter then local rec = state.inputs[ie.idx] if rec then rec.value = ie.buf refreshInputDisplay(ie.idx) end state.inputEdit = nil drawBar() -- авто-сабмит: ровно одно текстовое поле + одна submit-кнопка (паттерн поиска) if rec and rec.formIdx then local f = state.forms[rec.formIdx] local submitIdx, nSubmit, nText = nil, 0, 0 for _, ii in ipairs(f.inputs) do local r = state.inputs[ii] if r then if r.type == "submit" or r.type == "image" then submitIdx = ii; nSubmit = nSubmit + 1 elseif r.type ~= "hidden" and r.type ~= "button" and r.type ~= "reset" and r.type ~= "checkbox" and r.type ~= "radio" then nText = nText + 1 end end end if nSubmit == 1 and nText == 1 then submitForm(submitIdx) end end return elseif keycode == keyboard.keys.escape then state.inputEdit = nil; drawBar(); return elseif keycode == keyboard.keys.back then if ie.cursor > 0 then ie.buf = unicode.sub(ie.buf, 1, ie.cursor - 1) .. unicode.sub(ie.buf, ie.cursor + 1) ie.cursor = ie.cursor - 1 drawBar() end return elseif keycode == keyboard.keys.delete then ie.buf = unicode.sub(ie.buf, 1, ie.cursor) .. unicode.sub(ie.buf, ie.cursor + 2) drawBar(); return elseif keycode == keyboard.keys.left then ie.cursor = math.max(0, ie.cursor - 1); drawBar(); return elseif keycode == keyboard.keys.right then ie.cursor = math.min(unicode.len(ie.buf), ie.cursor + 1); drawBar(); return elseif keycode == keyboard.keys.home then ie.cursor = 0; drawBar(); return elseif keycode == keyboard.keys["end"] then ie.cursor = unicode.len(ie.buf); drawBar(); return elseif char and char ~= 0 and not keyboard.isControl(char) then local c = unicode.char(char) ie.buf = unicode.sub(ie.buf, 1, ie.cursor) .. c .. unicode.sub(ie.buf, ie.cursor + 1) ie.cursor = ie.cursor + 1 drawBar(); return end return end -- URL строка активна if state.urlFocus then if keycode == keyboard.keys.enter then local url = state.urlBar:match("^%s*(.-)%s*$") state.urlFocus = false navigate(url) elseif keycode == keyboard.keys.escape then state.urlFocus = false drawBar() elseif keycode == keyboard.keys.back then if state.urlCursor > 0 then state.urlBar = unicode.sub(state.urlBar, 1, state.urlCursor - 1) .. unicode.sub(state.urlBar, state.urlCursor + 1) state.urlCursor = state.urlCursor - 1 drawBar() end elseif keycode == keyboard.keys.delete then state.urlBar = unicode.sub(state.urlBar, 1, state.urlCursor) .. unicode.sub(state.urlBar, state.urlCursor + 2) drawBar() elseif keycode == keyboard.keys.left then state.urlCursor = math.max(0, state.urlCursor - 1) drawBar() elseif keycode == keyboard.keys.right then state.urlCursor = math.min(unicode.len(state.urlBar), state.urlCursor + 1) drawBar() elseif keycode == keyboard.keys.home then state.urlCursor = 0; drawBar() elseif keycode == keyboard.keys["end"] then state.urlCursor = unicode.len(state.urlBar); drawBar() elseif char and char ~= 0 and not keyboard.isControl(char) then local c = unicode.char(char) state.urlBar = unicode.sub(state.urlBar, 1, state.urlCursor) .. c .. unicode.sub(state.urlBar, state.urlCursor + 1) state.urlCursor = state.urlCursor + 1 drawBar() end return end -- глобальные горячие клавиши local ctrl = keyboard.isControlDown and keyboard.isControlDown() local alt = keyboard.isAltDown and keyboard.isAltDown() if keycode == keyboard.keys.f5 then navigate(state.url, false) elseif keycode == keyboard.keys.r and ctrl then openInReader(state.url) elseif keycode == keyboard.keys.l and ctrl then state.urlFocus = true state.urlBar = state.url state.urlCursor = unicode.len(state.urlBar) drawBar() elseif keycode == keyboard.keys.up then scrollBy(-1) elseif keycode == keyboard.keys.down then scrollBy(1) elseif keycode == keyboard.keys.pageUp then scrollBy(-PAGE_H) elseif keycode == keyboard.keys.pageDown then scrollBy(PAGE_H) elseif keycode == keyboard.keys.home then state.scroll = 0; drawPage() elseif keycode == keyboard.keys["end"] then state.scroll = math.max(0, #state.lines - PAGE_H); drawPage() elseif keycode == keyboard.keys.tab then if keyboard.isShiftDown and keyboard.isShiftDown() then selectNextLink(-1) else selectNextLink(1) end elseif keycode == keyboard.keys.enter then openSelectedLink() elseif keycode == keyboard.keys.left and alt then goBack() elseif keycode == keyboard.keys.right and alt then goForward() end end -- ═══════════════════════════════════════════════════════════════ -- ГЛАВНЫЙ ЦИКЛ -- ═══════════════════════════════════════════════════════════════ local function main() -- сохраняем оригинальные цвета local origFg = gpu.getForeground() local origBg = gpu.getBackground() local origW, origH = gpu.getResolution() -- очищаем экран setBg(C.bg) setFg(C.pageText) gpu.fill(1, 1, W, H, " ") -- начальная страница navigate("about:home") -- цикл событий while true do local ev = { event.pull(0.1) } local etype = ev[1] if etype == "key_down" then -- ev: type, addr, char, keycode, player handleKey(ev[4], ev[3] or 0, ev[5]) elseif etype == "touch" then -- ev: type, addr, x, y, btn, player handleClick(ev[3], ev[4], ev[5]) elseif etype == "scroll" then -- ev: type, addr, x, y, dir, player scrollBy(ev[5] > 0 and -3 or 3) elseif etype == "interrupted" then break end if state.fetchJob then stepFetchJob() elseif state.imgQueue and #state.imgQueue > 0 then processNextImage() end end -- восстанавливаем терминал setBg(origBg) setFg(origFg) gpu.fill(1, 1, W, H, " ") gpu.setResolution(origW, origH) term.setCursor(1, 1) print("OCBrowser завершён.") end -- запуск local ok, err = pcall(main) if not ok then io.stderr:write("Критическая ошибка: " .. tostring(err) .. "\n") end