help text: remove outdated Troubleshooting section
[gemini.koplugin.git] / client.lua
blob120a0dbe9f4029bc961ac83d7322cfadeaa64709
1 local gemini = require("gemini")
2 local Identities = require("identities")
3 local SchemeProxies = require("schemeproxies")
5 local BD = require("ui/bidi")
6 local WidgetContainer = require("ui/widget/container/widgetcontainer")
7 local Event = require("ui/event")
8 local Device = require("device")
9 local UIManager = require("ui/uimanager")
10 local ConfirmBox = require("ui/widget/confirmbox")
11 local InputDialog = require("ui/widget/inputdialog")
12 local MultiInputDialog = require("ui/widget/multiinputdialog")
13 local FileManager = require("apps/filemanager/filemanager")
14 local DocSettings = require("docsettings")
15 local DataStorage = require("datastorage")
16 local ReadHistory = require("readhistory")
17 local Trapper = require("ui/trapper")
18 local InfoMessage = require("ui/widget/infomessage")
19 local CheckButton = require("ui/widget/checkbutton")
20 local MultiConfirmBox = require("ui/widget/multiconfirmbox")
21 local SpinWidget = require("ui/widget/spinwidget")
22 local Menu = require("ui/widget/menu")
23 local Persist = require("persist")
24 local NetworkMgr = require("ui/network/manager")
25 local DocumentRegistry = require("document/documentregistry")
26 local Font = require("ui/font")
27 local TextViewer = require("ui/widget/textviewer")
28 local KeyValuePage = require("ui/widget/keyvaluepage")
29 local Screen = require("device").screen
30 local Version = require("version")
31 local filemanagerutil = require("apps/filemanager/filemanagerutil")
32 local lfs = require("libs/libkoreader-lfs")
33 local url = require("socket.url")
34 local sha256 = require("ffi/sha2").sha256
35 local util = require("util")
36 local ffiutil = require("ffi/util")
37 local _ = require("gettext")
38 local T = require("ffi/util").template
40 local gemini_dir = DataStorage:getDataDir() .. "/gemini"
41 local history_dir = "/tmp/gemini-history"
42 local queue_dir = "/tmp/gemini-queue"
43 local marks_path = gemini_dir .. "/bookmarks.gmi"
44 --local cafile_path = DataStorage:getDataDir() .. "/data/ca-bundle.crt"
46 local function getDefaultSavesDir()
47 local dir = G_reader_settings:readSetting("home_dir") or filemanagerutil.getDefaultDir()
48 if dir:sub(-1) ~= "/" then
49 dir = dir .. "/"
50 end
51 return dir .. "downloaded"
52 end
53 local save_dir = G_reader_settings:readSetting("gemini_save_dir") or getDefaultSavesDir()
55 local queue_persist = Persist:new{ path = gemini_dir .. "/queue.lua" }
56 local trust_store_persist = Persist:new{ path = gemini_dir .. "/known_hosts.lua" }
58 local Client = WidgetContainer:extend{
59 active = false, -- are we currently browsing gemini
60 activated = false, -- are we about to be browsing gemini at next init
62 hold_pan = false,
64 repl_purl = nil, -- parsed URL to request input at after init
66 history = {},
67 unhistory = {},
68 trust_overrides = {},
70 max_input_history = 10,
71 input_history = {},
73 queue = queue_persist:load() or {},
74 trust_store = trust_store_persist:load() or {},
77 local default_max_cache_history_items = 20
78 local max_cache_history_items = G_reader_settings:readSetting("gemini_max_cache_history_items") or default_max_cache_history_items
81 -- Parsed URL of current item
82 function Client:purl()
83 if #self.history > 0 then
84 return self.history[1].purl
85 end
86 end
88 function Client:init(ui)
89 self.ui = ui
90 self.active = self.activated
91 self.activated = false
92 if self.active then
93 assert(self:purl())
94 local postcb = self.ui.registerPostReaderReadyCallback or
95 self.ui.registerPostReadyCallback
96 -- Name for the callback in versions <= 2024.04
97 if postcb then
98 if self.ui.document.file then
99 -- Keep gemini history files out of reader history
100 postcb(self.ui, function ()
101 ReadHistory:removeItemByPath(self.ui.document.file)
102 end)
104 if self.repl_purl then
105 local local_repl_purl = self.repl_purl
106 postcb(self.ui, function ()
107 -- XXX: Input widget painted over without this delay. Better way?
108 UIManager:scheduleIn(0.1, function ()
109 self:promptInput(local_repl_purl, "[Repeating]", false, true)
110 end)
111 end)
115 -- disable statistics plugin (it's expensive and irrelevant)
116 self.ui:registerPostInitCallback(function ()
117 for i, v in ipairs(self.ui) do
118 if v == self.ui.statistics then
119 table.remove(self.ui, i)
120 break
123 self.ui.statistics = nil
124 end)
126 self.repl_purl = nil
128 if self.ui and self.ui.link then
129 if self.ui.link.registerScheme then
130 for _,scheme in ipairs(SchemeProxies:supportedSchemes()) do
131 self.ui.link:registerScheme(scheme)
133 if self.active then
134 self.ui.link:registerScheme("")
137 self.ui.link:addToExternalLinkDialog("23_gemini", function(this, link_url)
138 return {
139 text = _("Open via Gemini"),
140 callback = function()
141 UIManager:close(this.external_link_dialog)
142 this.ui:handleEvent(Event:new("FollowGeminiLink", link_url))
143 end,
144 show_in_dialog_func = function(u)
145 local scheme = u:match("^(%w[%w+%-.]*):") or ""
146 if scheme == "" and self.active then
147 return true
149 return util.arrayContains(SchemeProxies:supportedSchemes(), scheme)
150 end,
152 end)
155 self.ui.menu:registerToMainMenu(self)
157 if self.ui and self.ui.highlight then
158 self.ui.highlight:addToHighlightDialog("20_queue_links", function(this)
159 return {
160 text = _("Add links to queue"),
161 show_in_highlight_dialog_func = function()
162 return self.active
163 end,
164 callback = function()
165 self:queueLinksInSelected(this.selected_text)
166 this:onClose()
167 end,
169 end)
172 if self.active and self.ui.status then
173 self.ui.status.onEndOfBook = function() end
176 if self.active and Device:isTouchDevice() then
177 self.ui:registerTouchZones({
179 id = "tap_link_gemini",
180 ges = "tap",
181 screen_zone = {
182 ratio_x = 0, ratio_y = 0,
183 ratio_w = 1, ratio_h = 1,
185 overrides = {
186 -- Tap on gemini links has priority over everything
187 "tap_link",
188 "readerhighlight_tap",
189 "tap_top_left_corner",
190 "tap_top_right_corner",
191 "tap_left_bottom_corner",
192 "tap_right_bottom_corner",
193 "readerfooter_tap",
194 "readerconfigmenu_ext_tap",
195 "readerconfigmenu_tap",
196 "readermenu_ext_tap",
197 "readermenu_tap",
198 "tap_forward",
199 "tap_backward",
201 handler = function(ges) return self:onTap(nil, ges) end,
204 id = "hold_release_link_gemini",
205 ges = "hold_release",
206 screen_zone = {
207 ratio_x = 0, ratio_y = 0,
208 ratio_w = 1, ratio_h = 1,
210 overrides = {
211 "readerhighlight_hold_release",
213 handler = function(ges) return self:onHoldRelease(nil, ges) end,
216 id = "hold_pan_link_gemini",
217 ges = "hold_pan",
218 screen_zone = {
219 ratio_x = 0, ratio_y = 0,
220 ratio_w = 1, ratio_h = 1,
222 overrides = {
223 "readerhighlight_hold_pan",
225 handler = function(ges) return self:onHoldPan(nil, ges) end,
228 id = "double_tap_link_gemini",
229 ges = "double_tap",
230 screen_zone = {
231 ratio_x = 0, ratio_y = 0,
232 ratio_w = 1, ratio_h = 1,
234 overrides = {
235 "double_tap_top_left_corner",
236 "double_tap_top_right_corner",
237 "double_tap_bottom_left_corner",
238 "double_tap_bottom_right_corner",
239 "double_tap_left_side",
240 "double_tap_right_side",
242 handler = function(ges) return self:onDoubleTap(nil, ges) end,
245 id = "swipe_gemini",
246 ges = "swipe",
247 screen_zone = {
248 ratio_x = 0, ratio_y = 0,
249 ratio_w = 1, ratio_h = 1,
251 handler = function(ges) return self:onSwipe(nil, ges) end,
257 function Client:mimeToExt(mimetype)
258 return (mimetype == "text/plain" and "txt")
259 or DocumentRegistry:mimeToExt(mimetype)
260 or (mimetype and mimetype:find("^text/") and "txt")
263 local function writeBodyToFile(body, path)
264 local o = io.open(path, "w")
265 if o then
266 if type(body) == "string" then
267 o:write(body)
268 o:close()
269 return true
270 else
271 local chunk, aborted = body:read(256)
272 while chunk and chunk ~= "" do
273 o:write(chunk)
274 chunk, aborted = body:read(256)
276 body:close()
277 o:close()
278 return not aborted
280 else
281 return false
285 function Client:saveBody(body, mimetype, purl)
286 self:getSavePath(purl, mimetype, function(path)
287 if not writeBodyToFile(body, path) then
288 -- clear up partial write
289 FileManager:deleteFile(path, true)
291 end)
294 function Client:openBody(body, mimetype, purl, cert_info, replace_history)
295 util.makePath(history_dir)
297 local function get_ext(p)
298 if p.path then
299 local ext, m = p.path:gsub(".*%.","",1)
300 if m == 1 and ext:match("^%w*$") then
301 return ext
305 local hn = #self.history
306 if replace_history then
307 hn = hn - 1
309 local tn = history_dir .. "/Gemini " .. hn
310 local ext = self:mimeToExt(mimetype) or get_ext(purl)
311 if ext then
312 tn = tn .. "." .. ext
315 if not DocumentRegistry:hasProvider(tn) then
316 UIManager:show(ConfirmBox:new{
317 text = T(_("Can't view file (%1). Save it instead?"), mimetype or "unknown mimetype"),
318 ok_text = _("Save file"),
319 cancel_text = _("Cancel"),
320 ok_callback = function()
321 self:saveBody(body, mimetype, purl)
322 end,
324 return
327 if not replace_history then
328 -- Delete history tail
329 local ok, iter, dir_obj = pcall(lfs.dir, history_dir)
330 if not ok then
331 UIManager:show(InfoMessage:new{text = _("Failed to list history directory")})
332 return
335 for f in iter, dir_obj do
336 local path = history_dir.."/"..f
337 local attr = lfs.attributes(path) or {}
338 if attr.mode == "file" or attr.mode == "link" then
339 local n = tonumber(f:match("^Gemini (%d+)%f[^%d]"))
340 if n and (n >= #self.history or n <= #self.history - max_cache_history_items) then
341 FileManager:deleteFile(path, true)
342 local h = self.history[#self.history - n]
343 if h and h.path == path then
344 h.path = nil
349 while table.remove(self.unhistory) do end
352 if not writeBodyToFile(body, tn) then
353 return
356 local history_item = { purl = purl, path = tn, mimetype = mimetype, cert_info = cert_info }
357 if replace_history then
358 self.history[1] = history_item
359 else
360 table.insert(self.history, 1, history_item)
363 self:openCurrent()
366 function Client:openCurrent()
367 if not (self.history[1].path and util.fileExists(self.history[1].path)) then
368 return self:openUrl(self.history[1].purl, { replace_history = true })
371 local function switchDocumentSeamlessly(new_file)
372 if Version:getNormalizedCurrentVersion() >= 202408060000 then
373 self.ui:switchDocument(new_file, true)
374 else
375 -- backwards compatibility: seamless argument to switchDocument didn't exist
376 self.ui.tearing_down = true
377 self.ui.dithered = nil
379 self.ui:handleEvent(Event:new("CloseReaderMenu"))
380 self.ui:handleEvent(Event:new("CloseConfigMenu"))
381 self.ui.highlight:onClose() -- close highlight dialog if any
382 self.ui:onClose(false)
384 self.ui:showReader(new_file, nil, true)
388 local open_msg = InfoMessage:new{
389 text = T(_("%1\nOpening..."), gemini.showUrl(self.history[1].purl, true)),
391 UIManager:show(open_msg)
393 self.activated = true
395 if self.ui.name == "ReaderUI" then
396 --self.ui:switchDocument(history[1].path)
397 switchDocumentSeamlessly(self.history[1].path)
398 else
399 local ReaderUI = require("apps/reader/readerui")
400 ReaderUI:showReader(self.history[1].path, nil, true)
402 UIManager:close(open_msg)
405 function Client:writeDefaultBookmarks()
406 if not util.fileExists(marks_path) then
407 local f = io.open(marks_path, "w")
408 if f then
409 f:write(require("staticgemtexts").default_bookmarks)
410 f:close()
415 function Client:openUrl(article_url, opts)
416 if type(article_url) ~= "string" then
417 article_url = url.build(article_url)
419 opts = opts or {}
420 local body_cb = opts.body_cb or function(f, mimetype, p, cert_info)
421 self:openBody(f, mimetype, p, cert_info, opts.replace_history)
423 if self:purl() then
424 article_url = url.absolute(self:purl(), article_url)
427 local function fail(msg)
428 UIManager:show(InfoMessage:new{
429 text = msg,
430 dismiss_callback = opts.after_err_cb,
434 local purl = url.parse(article_url, {port = "1965"})
436 if purl and purl.scheme == "about" then
437 local body
438 if purl.path == "bookmarks" then
439 self:writeDefaultBookmarks()
440 G_reader_settings:makeTrue("gemini_initiated")
441 body = io.open(marks_path, "r")
442 else
443 body = require("staticgemtexts")[purl.path]
445 if body then
446 body_cb(body, "text/gemini", purl)
447 else
448 fail(T(_("Unknown \"about:\" URL: %1"), article_url))
450 return
453 if purl and purl.scheme == "file" then
454 if purl.host and purl.host ~= "" and purl.host ~= "localhost" then
455 return fail(T(_("Can't open file URI with non-local host %1."), purl.host))
456 elseif not purl.path then
457 return fail(_("Can't open file URI with empty path."))
459 local attr = lfs.attributes(purl.path) or {}
460 if attr.mode ~= "file" and attr.mode ~= "link" then
461 return fail(_("No such file."))
463 local body = io.open(purl.path, "r")
464 if body then
465 return body_cb(body, nil, purl)
466 else
467 return fail(T(_("Failed to open path %1"), purl.path))
471 if not purl or not purl.host then
472 return fail(T(_("Invalid URL: %1"), article_url))
475 local proxy = SchemeProxies:get(purl.scheme)
476 if purl.scheme ~= "gemini" and purl.scheme ~= "titan" and not proxy then
477 return fail(T(_("No proxy configured for scheme: %1"), purl.scheme))
480 if NetworkMgr:willRerunWhenConnected(function() self:openUrl(article_url, opts) end) then
481 return
484 local success_cb
485 if purl and purl.scheme == "titan" then
486 -- Putting this after willRerunWhenConnected, because that seems not
487 -- to be reliable and we don't want the user to lose what they
488 -- composed while offline.
489 local titan = require("titan")
490 local function titan_cb(u, b, mimetype)
491 opts.titan_body = b
492 opts.after_err_cb = function()
493 titan.doTitan(titan_cb, u, b, mimetype, true)
495 self:openUrl(u, opts)
497 -- Warning: url.parse follows RFC 2396 rather than 3986, so doesn't
498 -- parse valueless parameters like ";edit".
499 if purl.path and article_url:match(";edit$") then
500 -- This implements the extension to the Titan protocol described
501 -- at gemini://transjovian.org/titan/page/Edit%20Link
502 success_cb = function(f, mimetype, params, cert_info)
503 local b = f:read("a")
504 f:close()
505 titan.doTitan(titan_cb, url.build(purl), b, mimetype)
507 elseif not opts.titan_body then
508 return titan.doTitan(titan_cb, article_url)
512 local id, __, id_path = Identities:get(article_url)
513 success_cb = success_cb or function(f, mimetype, params, cert_info)
514 if opts.repl_purl then
515 self.repl_purl = opts.repl_purl
517 body_cb(f, mimetype, purl, cert_info)
519 local function error_cb(msg, major, minor, meta)
520 if major then
521 if meta and #meta > 0 then
522 msg = T(_("Server reports %1: %2"), msg, meta)
523 else
524 msg = T(_("Server reports %1"), msg)
527 if major == "1" then
528 self:promptInput(purl, meta, minor == "1", false, nil, opts)
529 elseif major == "3" then
530 opts.num_redirects = opts.num_redirects or 0
531 if opts.num_redirects >= 5 then
532 return fail(_("Too many redirects."))
533 else
534 local new_uri = url.absolute(purl, meta)
535 local pnew = url.parse(new_uri)
536 if not pnew then
537 return fail(T("BUG: Unparseable URI on redirection: %1"), meta)
539 -- TODO: automatically edit bookmarks file if permanent?
540 opts.num_redirects = opts.num_redirects + 1
541 opts.titan_body = nil
542 local function confirm_redir(t)
543 UIManager:show(ConfirmBox:new{
544 text = t,
545 ok_text = _("Follow"),
546 cancel_text = _("Cancel"),
547 ok_callback = function()
548 self:openUrl(new_uri, opts)
549 end,
552 if pnew.scheme ~= purl.scheme and
553 not ((pnew.scheme == "gemini" and purl.scheme == "titan") or
554 (pnew.scheme == "titan" and purl.scheme == "gemini")) then
555 return confirm_redir(T(_("Follow cross-scheme redirect to %1?"), new_uri))
557 local new_id = Identities:get(new_uri)
558 if new_id and id ~= new_id then
559 return confirm_redir(T(_("Follow redirect to %1 using identity %2?"), new_uri, new_id))
561 self:openUrl(new_uri, opts)
563 elseif major == "6" then
564 UIManager:show(ConfirmBox:new{
565 text = msg,
566 ok_text = _("Set identity"),
567 cancel_text = _("Cancel"),
568 ok_callback = function()
569 Identities:confAt(gemini.showUrl(purl), function(new_id)
570 if new_id then
571 self:openUrl(purl, opts)
573 end)
574 end,
576 else
577 fail(msg)
580 local function check_trust_cb(host, new_fp, old_trusted_times, old_expiry, cb)
581 if self.trust_overrides[new_fp] and os.time() < self.trust_overrides[new_fp] then
582 cb("once")
583 else
584 self:promptUnexpectedCert(host, new_fp, old_trusted_times, old_expiry, cb)
587 local function trust_modified_cb()
588 trust_store_persist:save(self.trust_store)
590 local function info_cb(msg, fast)
591 return Trapper:info(msg, fast)
594 Trapper:wrap(function()
595 Trapper:setPausedText(T(_("Abort connection?")))
596 gemini.makeRequest(gemini.showUrl(purl),
597 id and id_path..".key",
598 id and id_path..".crt",
599 nil, -- disable CA-based verification
600 self.trust_store,
601 check_trust_cb,
602 trust_modified_cb,
603 success_cb,
604 error_cb,
605 info_cb,
606 G_reader_settings:isTrue("gemini_confirm_tofu"),
607 proxy and proxy.host,
608 opts.titan_body)
609 Trapper:reset()
610 end)
613 -- Prompt user for input. May modify purl.
614 function Client:promptInput(purl, prompt, is_secret, repl, initial, openUrl_opts)
615 local input_dialog
616 local repl_button
617 local multiline_button
618 local function submit()
619 local input = input_dialog:getInputText()
620 purl.query = input
621 gemini.escape(purl)
622 if #url.build(purl) > 1024 then
623 UIManager:show(InfoMessage:new{ text =
624 T(_("Input too long (by %1 bytes)"), #url.build(purl) - 1024) })
625 else
626 UIManager:close(input_dialog)
627 table.insert(self.input_history, 1, input)
628 if #self.input_history > self.max_input_history then
629 table.remove(self.input_history, #self.input_history)
631 local opts = openUrl_opts or {}
632 opts.repl_purl = repl_button.checked and purl
633 opts.after_err_cb = function()
634 self:promptInput(purl, prompt, is_secret, repl_button.checked, input, openUrl_opts)
636 self:openUrl(purl, opts)
639 local hi = 0
640 local latest_input
641 local function update_buttons()
642 local prev_button = input_dialog.button_table:getButtonById("prev")
643 prev_button:enableDisable(#self.input_history > hi)
644 UIManager:setDirty(prev_button, "ui")
645 local next_button = input_dialog.button_table:getButtonById("next")
646 next_button:enableDisable(hi > 0)
647 UIManager:setDirty(next_button, "ui")
649 local function to_hist(i)
650 if hi == 0 then
651 latest_input = input_dialog:getInputText()
653 hi = i
654 input_dialog:setInputText(hi > 0 and self.input_history[hi] or latest_input, nil, false)
655 update_buttons()
657 input_dialog = InputDialog:new{
658 title = prompt,
659 text_type = is_secret and "password",
660 enter_callback = submit,
661 buttons = {
664 text = _("Cancel"),
665 id = "close",
666 callback = function()
667 UIManager:close(input_dialog)
668 end,
671 icon = "move.up",
672 id = "prev",
673 callback = function() to_hist(hi+1) end,
674 hold_callback = function() to_hist(#self.input_history) end
677 icon = "move.down",
678 id = "next",
679 callback = function() to_hist(hi-1) end,
680 hold_callback = function() to_hist(0) end
683 text = _("Enter"),
684 callback = submit,
689 if initial then
690 input_dialog:setInputText(initial, nil, false)
692 update_buttons()
694 -- read-eval-print-loop mode: keep presenting input dialog
695 repl_button = CheckButton:new{
696 text = _("Repeat"),
697 checked = repl,
698 parent = input_dialog,
700 multiline_button = CheckButton:new{
701 text = _("Multiline input"),
702 checked = false,
703 callback = function()
704 if input_dialog.setAllowNewline then
705 input_dialog:setAllowNewline(multiline_button.checked)
706 else
707 -- backwards compatibility for <= 2024.07
708 input_dialog.allow_newline = multiline_button.checked
709 if multiline_button.checked then
710 input_dialog._input_widget.enter_callback = nil
711 else
712 input_dialog._input_widget.enter_callback = submit
715 end,
716 parent = input_dialog,
718 input_dialog:addWidget(multiline_button)
719 input_dialog:addWidget(repl_button)
721 local y_offset = 0
722 if repl then
723 -- Draw just above keyboard (in vertical mode),
724 -- so we can see as much as possible of the newly loaded page
725 y_offset = Screen:scaleBySize(120)
726 if G_reader_settings:isTrue("keyboard_key_compact") then
727 y_offset = y_offset + 50
731 UIManager:show(input_dialog, nil, nil, nil, y_offset)
732 input_dialog:onShowKeyboard()
733 return true
736 function Client:userPromptInput(purl)
737 self:promptInput(purl, "[Input]", false, false, url.unescape(purl.query or ""))
740 function Client:promptUnexpectedCert(host, new_fp, old_trusted_times, old_expiry, cb)
741 local widget = MultiConfirmBox:new{
742 text = old_trusted_times > 0 and
743 T(_([[
744 The server identity presented by %1 does not match that previously trusted (%2 times).
745 Digest of received certificate: SHA256:%3
746 Previously trusted certificate expiry date: %4]]), host, old_trusted_times, new_fp, old_expiry) or
747 T(_([[
748 No trusted server identity known for %1. Trust provided server identity?
749 Digest of received certificate: SHA256:%2]]), host, new_fp),
750 face = Font:getFace("x_smallinfofont"),
751 choice1_text = _("Trust new certificate"),
752 choice1_callback = function()
753 cb("always")
754 end,
755 choice2_text = _("Connect without trust"),
756 choice2_callback = function()
757 -- persist for 1h
758 self.trust_overrides[new_fp] = os.time() + 3600
759 cb("once")
760 end,
761 cancel_callback = function()
762 cb()
763 end,
765 UIManager:show(widget)
768 function Client:goBack(n)
769 n = n or 1
770 if n > #self.history-1 then
771 n = #self.history-1
772 elseif n < -#self.unhistory then
773 n = -#self.unhistory
775 if n == 0 then
776 return false
778 while n > 0 do
779 table.insert(self.unhistory, 1, table.remove(self.history, 1))
780 n = n-1
782 while n < 0 do
783 table.insert(self.history, 1, table.remove(self.unhistory, 1))
784 n = n+1
786 self:openCurrent()
787 return true
790 function Client:clearHistory()
791 local function delete_item(item)
792 if item.path then
793 FileManager:deleteFile(item.path, true)
797 while #self.history > 1 do
798 delete_item(table.remove(self.history, 2))
800 while #self.unhistory > 0 do
801 delete_item(table.remove(self.unhistory, 1))
805 function Client:onTap(_, ges)
806 if self.active then
807 return self:followGesLink(ges)
811 function Client:onDoubleTap(_, ges)
812 if self.active then
813 return self:followGesLink(ges, true)
817 function Client:onHoldPan(_, ges)
818 self.hold_pan = true
821 function Client:onHoldRelease(_, ges)
822 if self.active and not self.hold_pan then
823 if self:followGesLink(ges, true) then
824 if self.ui.highlight then
825 self.ui.highlight:clear()
827 return true
830 self.hold_pan = false
833 function Client:onSwipe(_, ges)
834 if self.active then
835 local direction = BD.flipDirectionIfMirroredUILayout(ges.direction)
836 if direction == "south" then
837 return self:goBack()
838 elseif direction == "north" then
839 self:showNav()
840 return true
845 function Client:followGesLink(ges, nav)
846 local link = self.ui.link:getLinkFromGes(ges)
847 if link and link.xpointer then
848 local scheme = link.xpointer:match("^(%w[%w+%-.]*):") or ""
849 if util.arrayContains(SchemeProxies:supportedSchemes(), scheme)
850 or (scheme == "" and self.active) then
851 if nav then
852 self:showNav(link.xpointer)
853 else
854 self:openUrl(link.xpointer)
856 return true
861 function Client:onFollowGeminiLink(u)
862 return self:showNav(u)
865 function Client:onEndOfBook()
866 if self.active then
867 if G_reader_settings:isTrue("gemini_next_on_end") and #self.queue > 0 then
868 self:openQueueItem()
869 else
870 self:showNav()
872 return true
876 function Client:queueLinksInSelected(selected)
877 local html = self.ui.document:getHTMLFromXPointers(selected.pos0, selected.pos1, nil, true)
878 if html then
879 -- Following pattern isn't strictly correct in general,
880 -- but is for the html generated from a gemini document.
881 local n = 0
882 for u in html:gmatch('<a[^>]*href="([^"]*)"') do
883 self:queueLink(u)
884 n = n + 1
886 UIManager:show(InfoMessage:new{ text =
887 n == 0 and _("No links found in selected text.") or
888 T(_("Added %1 links to queue."), n)
893 function Client:queueBody(body, u, mimetype, cert_info, existing_item, prepend)
894 util.makePath(queue_dir)
895 local path = queue_dir.."/"..sha256(u)
896 if writeBodyToFile(body, path) then
897 if existing_item then
898 existing_item.path = path
899 existing_item.mimetype = mimetype
900 existing_item.cert_info = cert_info
901 else
902 self:queueItem({ url = u, path = path, mimetype = mimetype, cert_info = cert_info }, prepend)
904 elseif not existing_item then
905 self:queueItem({ url = u }, prepend)
909 function Client:queueCachedHistoryItem(h, prepend)
910 local body = io.open(h.path, "r")
911 if body then
912 self:queueBody(body, gemini.showUrl(h.purl), h.mimetype, h.cert_info, nil, prepend)
916 function Client:fetchLink(u, item, prepend)
917 self:openUrl(u, { body_cb = function(body, mimetype, purl, cert_info)
918 self:queueBody(body, gemini.showUrl(purl), mimetype, cert_info, item, prepend)
919 end})
922 function Client:fetchQueue()
923 for _n,item in ipairs(self.queue) do
924 if not item.path then
925 self:fetchLink(item.url, item)
930 function Client:queueLink(u, prepend)
931 local purl = url.parse(u)
932 if purl and purl.scheme ~= "about" and
933 not G_reader_settings:isTrue("gemini_no_fetch_on_add") and NetworkMgr:isConnected() then
934 self:fetchLink(u, nil, prepend)
935 else
936 self:queueItem({ url = u }, prepend)
940 function Client:queueItem(item, prepend)
941 for k = #self.queue,1,-1 do
942 if self.queue[k].url == item.url then
943 table.remove(self.queue,k)
946 if prepend then
947 table.insert(self.queue, 1, item)
948 else
949 table.insert(self.queue, item)
951 queue_persist:save(self.queue)
954 function Client:openQueueItem(n)
955 n = n or 1
956 local item = self.queue[n]
957 if item then
958 if item.path then
959 local f = io.open(item.path, "r")
960 if not f then
961 UIManager:show(InfoMessage:new{text = T(_("Failed to open %1 for reading."), item.path)})
962 else
963 self:openBody(f, item.mimetype, url.parse(item.url), item.cert_info)
964 FileManager:deleteFile(item.path, true)
965 self:popQueue(n)
967 elseif item.url:match("^about:") or NetworkMgr:isConnected() then
968 self:openUrl(item.url)
969 self:popQueue(n)
970 else
971 UIManager:show(InfoMessage:new{text = T(_("Need network connection to fetch %1"), item.url)})
976 function Client:popQueue(n)
977 n = n or 1
978 local item = table.remove(self.queue, n)
979 queue_persist:save(self.queue)
980 return item
983 function Client:clearQueue()
984 while #self.queue > 0 do
985 local item = table.remove(self.queue, 1)
986 if item.path then
987 FileManager:deleteFile(item.path, true)
990 queue_persist:save(self.queue)
993 function Client:getSavePath(purl, mimetype, cb)
994 local basename = ""
995 local add_ext = false
996 if purl.path then
997 basename = purl.path:gsub("/+$","",1):gsub(".*/","",1)
998 if basename == "" and purl.host then
999 basename = purl.host
1000 add_ext = true
1002 if add_ext or not basename:match(".+%..+") then
1003 local ext = self:mimeToExt(mimetype)
1004 if ext then
1005 basename = basename.."."..ext
1010 local widget
1012 local function do_save()
1013 local fields = widget:getFields()
1014 local dir = fields[2]
1015 local bn = fields[1]
1016 if bn ~= "" then
1017 local path = dir.."/"..bn
1018 local tp = lfs.attributes(path, "mode")
1019 if tp == "directory" then
1020 UIManager:show(InfoMessage:new{text = _("Path is a directory")})
1021 elseif tp ~= nil then
1022 UIManager:show(ConfirmBox:new{
1023 text = _("File exists. Overwrite?"),
1024 ok_text = _("Overwrite"),
1025 cancel_text = _("Cancel"),
1026 ok_callback = function()
1027 UIManager:close(widget)
1028 cb(path)
1029 end,
1031 else
1032 UIManager:close(widget)
1033 util.makePath(dir)
1034 cb(path)
1039 widget = MultiInputDialog:new{
1040 title = _("Save as"),
1041 fields = {
1043 description = _("Filename"),
1044 text = basename,
1047 description = _("Directory to save under"),
1048 text = save_dir,
1051 buttons = {
1054 text = _("Cancel"),
1055 id = "close",
1056 callback = function()
1057 UIManager:close(widget)
1058 end,
1061 text = _("Save"),
1062 is_enter_default = true,
1063 callback = do_save,
1067 enter_callback = do_save,
1069 if Version:getNormalizedCurrentVersion() < 202408060000 then
1070 if widget.input_fields then
1071 widget.input_fields[1].enter_callback = do_save
1072 widget.input_fields[2].enter_callback = do_save
1073 elseif widget.input_field then
1074 -- backwards compatibility for <2024.07
1075 widget.input_field[1].enter_callback = do_save
1076 widget.input_field[2].enter_callback = do_save
1079 UIManager:show(widget)
1080 widget:onShowKeyboard()
1083 function Client:saveCurrent()
1084 self:getSavePath(self.history[1].purl, self.history[1].mimetype, function(path)
1085 ffiutil.copyFile(self.history[1].path, path)
1086 self.ui:saveSettings()
1087 if DocSettings.updateLocation then
1088 DocSettings.updateLocation(self.history[1].path, path, true)
1090 end)
1093 function Client:addMark(u, desc)
1094 if url.parse(u) then
1095 self:writeDefaultBookmarks()
1096 local line = "=> " .. u
1097 if desc and desc ~= "" then
1098 line = line .. " " .. desc
1100 line = line .. "\n"
1101 local f = io.open(marks_path, "a")
1102 if f then
1103 f:write(line)
1104 f:close()
1105 return true
1110 function Client:addMarkInteractive(uri)
1111 local widget
1112 local function add_mark()
1113 local fields = widget:getFields()
1114 if self:addMark(fields[2], fields[1]) then
1115 UIManager:close(widget)
1118 widget = MultiInputDialog:new{
1119 title = _("Add bookmark"),
1120 fields = {
1122 description = _("Description (optional)"),
1125 description = _("URL"),
1126 text = gemini.showUrl(uri),
1129 buttons = {
1132 text = _("Cancel"),
1133 id = "close",
1134 callback = function()
1135 UIManager:close(widget)
1136 end,
1139 text = _("Add bookmark"),
1140 is_enter_default = true,
1141 callback = add_mark,
1145 enter_callback = add_mark,
1147 if Version:getNormalizedCurrentVersion() < 202408060000 then
1148 if widget.input_fields then
1149 widget.input_fields[1].enter_callback = add_mark
1150 widget.input_fields[2].enter_callback = add_mark
1151 elseif widget.input_field then
1152 -- backwards compatibility for <2024.07
1153 widget.input_field[1].enter_callback = add_mark
1154 widget.input_field[2].enter_callback = add_mark
1157 UIManager:show(widget)
1158 widget:onShowKeyboard()
1161 function Client:showHistoryMenu(cb)
1162 cb = cb or function(n) self:goBack(n) end
1163 local menu
1164 local history_items = {}
1165 local function show_history_item(h)
1166 return gemini.showUrl(h.purl) ..
1167 (h.path and " " .. _("(fetched)") or "")
1169 for n,h in ipairs(self.history) do
1170 table.insert(history_items, {
1171 text = T("%1 %2", n-1, show_history_item(h)),
1172 callback = function()
1173 cb(n-1)
1174 UIManager:close(menu)
1175 end,
1176 hold_callback = function()
1177 UIManager:close(menu)
1178 self:showNav(h.purl)
1179 end,
1182 for n,h in ipairs(self.unhistory) do
1183 table.insert(history_items, 1, {
1184 text = T("%1 %2", -n, show_history_item(h)),
1185 callback = function()
1186 cb(-n)
1187 UIManager:close(menu)
1191 if #history_items > 1 then
1192 table.insert(history_items, {
1193 text = _("Clear all history"),
1194 callback = function()
1195 UIManager:show(ConfirmBox:new{
1196 text = T(_("Clear %1 history items?"), #history_items-1),
1197 ok_text = _("Clear history"),
1198 cancel_text = _("Cancel"),
1199 ok_callback = function()
1200 self:clearHistory()
1201 UIManager:close(menu)
1202 end,
1207 menu = Menu:new{
1208 title = _("History"),
1209 item_table = history_items,
1210 onMenuHold = function(_, item)
1211 if item.hold_callback then
1212 item.hold_callback()
1214 end,
1215 width = Screen:getWidth(), -- backwards compatibility;
1216 height = Screen:getHeight(), -- can delete for KOReader >= 2023.04
1218 UIManager:show(menu)
1221 function Client:viewCurrentAsText()
1222 local h = self.history[1]
1223 local f = io.open(h.path,"r")
1224 UIManager:show(TextViewer:new{
1225 title = gemini.showUrl(h.purl),
1226 text = f and f:read("a") or "[Error reading file]"
1228 f:close()
1231 function Client:showCurrentInfo()
1232 local h = self.history[1]
1233 local kv_pairs = {
1234 { _("URL"), gemini.showUrl(h.purl) },
1235 { _("Mimetype"), h.mimetype }
1237 local widget
1238 if h.cert_info then
1239 table.insert(kv_pairs, "----")
1240 local cert_info = self.history[1].cert_info
1241 if cert_info.ca then
1242 table.insert(kv_pairs, { _("Trust type"), _("Chain to Certificate Authority") })
1243 for k, v in ipairs(cert_info.ca) do
1244 table.insert(kv_pairs, { v.name, v.value })
1246 else
1247 if cert_info.trusted_times > 0 then
1248 table.insert(kv_pairs, { _("Trust type"), _("Trust On First Use"), callback = function()
1249 UIManager:close(widget)
1250 self:openUrl("about:tofu")
1251 end })
1252 table.insert(kv_pairs, { _("SHA256 digest"), cert_info.fp })
1253 table.insert(kv_pairs, { _("Times seen"), cert_info.trusted_times })
1254 else
1255 table.insert(kv_pairs, { _("Trust type"), _("Temporarily accepted") })
1256 table.insert(kv_pairs, { _("SHA256 digest"), cert_info.fp })
1258 table.insert(kv_pairs, { _("Expiry date"), cert_info.expiry })
1261 table.insert(kv_pairs, "----")
1262 table.insert(kv_pairs, { "Source", _("Select to view page as text"), callback = function()
1263 self:viewCurrentAsText()
1264 end })
1265 widget = KeyValuePage:new{
1266 title = _("Page info"),
1267 kv_pairs = kv_pairs,
1269 UIManager:show(widget)
1272 function Client:editQueue()
1273 local menu
1274 local items = {}
1275 local function show_queue_item(item)
1276 return gemini.showUrl(item.url) ..
1277 (item.path and " " .. _("(fetched)") or "")
1279 local unfetched = 0
1280 for n,item in ipairs(self.queue) do
1281 table.insert(items, {
1282 text = n .. " " .. show_queue_item(item),
1283 callback = function()
1284 UIManager:close(menu)
1285 self:openQueueItem(n)
1286 end,
1287 hold_callback = function()
1288 UIManager:close(menu)
1289 self:showNav(item.url)
1290 end,
1292 if not item.path then
1293 unfetched = unfetched + 1
1296 if unfetched > 0 then
1297 table.insert(items, {
1298 text = T(_("Fetch %1 unfetched items"), unfetched),
1299 callback = function()
1300 self:fetchQueue()
1301 UIManager:close(menu)
1302 self:editQueue()
1306 if #items > 0 then
1307 table.insert(items, {
1308 text = _("Clear queue"),
1309 callback = function()
1310 UIManager:show(ConfirmBox:new{
1311 text = T(_("Clear %1 items from queue?"), #self.queue),
1312 ok_text = _("Clear queue"),
1313 cancel_text = _("Cancel"),
1314 ok_callback = function()
1315 self:clearQueue()
1316 UIManager:close(menu)
1317 end,
1322 menu = Menu:new{
1323 title = _("Queue"),
1324 item_table = items,
1325 onMenuHold = function(_, item)
1326 if item.hold_callback then
1327 item.hold_callback()
1329 end,
1330 width = Screen:getWidth(), -- backwards compatibility;
1331 height = Screen:getHeight(), -- can delete for KOReader >= 2023.04
1333 UIManager:show(menu)
1336 function Client:showNav(uri, showKbd)
1337 if uri and type(uri) ~= "string" then
1338 uri = url.build(uri)
1340 showKbd = showKbd or not uri or uri == ""
1341 if not uri then
1342 uri = gemini.showUrl(self:purl())
1343 elseif self:purl() and uri ~= "" then
1344 uri = url.absolute(self:purl(), uri)
1347 local nav
1348 local advanced = false
1349 local function current_nav_url()
1350 local u = nav:getInputText()
1351 if u:match("^[./?]") then
1352 -- explicitly relative url
1353 if self:purl() then
1354 u = url.absolute(self:purl(), u)
1356 else
1357 -- absolutise if necessary
1358 local purl = url.parse(u)
1359 if purl and purl.scheme == nil and purl.host == nil then
1360 u = "gemini://" .. u
1363 return u
1365 local function current_input_nonempty()
1366 local purl = url.parse(current_nav_url())
1367 return purl and (purl.host or purl.path)
1369 local function close_nav_keyboard()
1370 if nav.onCloseKeyboard then
1371 nav:onCloseKeyboard()
1372 elseif Version:getNormalizedCurrentVersion() < 202309010000 then
1373 -- backwards compatibility
1374 if nav._input_widget.onCloseKeyboard then
1375 nav._input_widget:onCloseKeyboard()
1379 local function show_hist()
1380 close_nav_keyboard()
1381 self:showHistoryMenu(function(n)
1382 UIManager:close(nav)
1383 self:goBack(n)
1384 end)
1386 local function queue_nav_url(prepend)
1387 if current_input_nonempty() then
1388 local u = current_nav_url()
1389 if u == gemini.showUrl(self:purl()) and self.history[1].path then
1390 self:queueCachedHistoryItem(self.history[1], prepend)
1391 else
1392 self:queueLink(u, prepend)
1394 UIManager:close(nav)
1397 local function update_buttons()
1398 local u = current_nav_url()
1399 local purl = url.parse(u)
1400 local id = Identities:get(u)
1401 local text = T(_("Identity: %1"), id or _("[none]"))
1402 local id_button = nav.button_table:getButtonById("ident")
1403 if not advanced then
1404 id_button:setText(text, id_button.width)
1406 id_button:enableDisable(advanced or (purl and purl.scheme == "gemini" and purl.host ~= ""))
1407 UIManager:setDirty(id_button, "ui")
1409 local save_button = nav.button_table:getButtonById("save")
1410 save_button:enableDisable(purl and purl.scheme and purl.scheme ~= "about")
1411 UIManager:setDirty(save_button, "ui")
1413 local info_button = nav.button_table:getButtonById("info")
1414 info_button:enableDisable(u == gemini.showUrl(self:purl()))
1415 UIManager:setDirty(info_button, "ui")
1417 local function toggle_advanced()
1418 advanced = not advanced
1419 for _,row in ipairs(nav.button_table.buttons_layout) do
1420 for _,button in ipairs(row) do
1421 if button.text_func and button.hold_callback then
1422 button:setText(button.text_func(), button.width)
1423 button.callback, button.hold_callback = button.hold_callback, button.callback
1427 update_buttons()
1428 UIManager:setDirty(nav, "ui")
1431 nav = InputDialog:new{
1432 title = _("Gemini navigation"),
1433 width = Screen:scaleBySize(550), -- in pixels
1434 input_type = "text",
1435 input = uri and gemini.showUrl(uri) or "gemini://",
1436 edited_callback = function(edited) if edited then update_buttons() end end,
1437 buttons = {
1440 text_func = function() return advanced and _("Edit identity URLs") or _("Identity") end,
1441 id = "ident",
1442 callback = function()
1443 close_nav_keyboard()
1444 Identities:confAt(current_nav_url(), function()
1445 update_buttons()
1446 end)
1447 end,
1448 hold_callback = function()
1449 close_nav_keyboard()
1450 Identities:edit()
1451 end,
1454 text_func = function() return advanced and _("View as text") or _("Page info") end,
1455 id = "info",
1456 callback = function()
1457 UIManager:close(nav)
1458 self:showCurrentInfo()
1459 end,
1460 hold_callback = function()
1461 UIManager:close(nav)
1462 self:viewCurrentAsText()
1463 end,
1468 text_func = function() return advanced and _("History") or _("Back") end,
1469 enabled = #self.history > 1,
1470 callback = function()
1471 UIManager:close(nav)
1472 self:goBack()
1473 end,
1474 hold_callback = show_hist,
1477 text_func = function() return advanced and _("History") or _("Unback") end,
1478 enabled = #self.unhistory > 0,
1479 callback = function()
1480 UIManager:close(nav)
1481 self:goBack(-1)
1482 end,
1483 hold_callback = show_hist,
1486 text_func = function() return advanced and _("Edit queue") or _("Next") end,
1487 enabled = #self.queue > 0,
1488 callback = function()
1489 UIManager:close(nav)
1490 self:openQueueItem()
1491 end,
1492 hold_callback = function()
1493 UIManager:close(nav)
1494 self:editQueue()
1495 end,
1498 text_func = function() return advanced and _("Edit marks") or _("Bookmarks") end,
1499 callback = function()
1500 UIManager:close(nav)
1501 self:openUrl("about:bookmarks")
1502 end,
1503 hold_callback = function()
1504 if self.ui.texteditor and self.ui.texteditor.quickEditFile then
1505 UIManager:close(nav)
1506 self:writeDefaultBookmarks()
1507 local function done_cb()
1508 if self:purl() and url.build(self:purl()) == "about:bookmarks" then
1509 self:openUrl("about:bookmarks", { replace_history = true })
1512 self.ui.texteditor:quickEditFile(marks_path, done_cb, true)
1513 else
1514 UIManager:show(InfoMessage:new{text = T(_([[
1515 Can't load TextEditor: Plugin disabled or incompatible.
1516 To edit bookmarks, please edit the file %1 in the koreader directory manually.
1517 ]]), marks_path)})
1519 end,
1524 text_func = function() return advanced and _("Root") or _("Up") end,
1525 callback = function()
1526 nav:setInputText(gemini.upUrl(current_nav_url()))
1527 update_buttons()
1528 end,
1529 hold_callback = function()
1530 nav:setInputText(url.absolute(current_nav_url(), "/"))
1531 update_buttons()
1532 end,
1535 text = _("Save"),
1536 id = "save",
1537 callback = function()
1538 local u = current_nav_url()
1539 local purl = url.parse(u)
1540 if purl and purl.scheme and purl.scheme == "about" then
1541 UIManager:show(InfoMessage:new{text = _("Can't save about: pages")})
1542 elseif u == gemini.showUrl(self:purl()) then
1543 UIManager:close(nav)
1544 self:saveCurrent()
1545 else
1546 UIManager:close(nav)
1547 self:openUrl(u, { body_cb = function(f, mimetype, p2)
1548 self:saveBody(f, mimetype, p2)
1549 end })
1551 end,
1554 text_func = function() return advanced and _("Prepend") or _("Add") end,
1555 callback = queue_nav_url,
1556 hold_callback = function() queue_nav_url(true) end,
1559 text_func = function() return advanced and _("Quick mark") or _("Mark") end,
1560 callback = function()
1561 if current_input_nonempty() then
1562 self:addMarkInteractive(current_nav_url())
1563 UIManager:close(nav)
1565 end,
1566 hold_callback = function()
1567 if current_input_nonempty()
1568 and self:addMark(current_nav_url()) then
1569 UIManager:close(nav)
1571 end,
1576 text = _("Cancel"),
1577 id = "close",
1578 callback = function()
1579 UIManager:close(nav)
1580 end,
1583 text_func = function() return advanced and _("Input") or _("Go") end,
1584 is_enter_default = true,
1585 callback = function()
1586 UIManager:close(nav)
1587 local u = current_nav_url()
1588 self:openUrl(u, { after_err_cb = function() self:showNav(u, true) end})
1589 end,
1590 hold_callback = function()
1591 local purl = url.parse(current_nav_url())
1592 if purl then
1593 self:userPromptInput(purl)
1594 UIManager:close(nav)
1596 end,
1601 update_buttons()
1602 if Version:getNormalizedCurrentVersion() < 202408060000 then
1603 nav._input_widget.edit_callback = nav.edited_callback
1606 nav.title_bar.right_icon = "appbar.settings"
1607 nav.title_bar.right_icon_tap_callback = toggle_advanced
1608 nav.title_bar:init()
1610 UIManager:show(nav)
1611 if showKbd then
1612 nav:onShowKeyboard()
1616 function Client:onBrowseGemini()
1617 if self.active then
1618 self:showNav()
1619 elseif #self.history > 0 then
1620 self:openCurrent()
1621 elseif G_reader_settings:nilOrFalse("gemini_initiated") then
1622 self:openUrl("about:welcome")
1623 else
1624 self:openUrl("about:bookmarks")
1626 return true
1629 function Client:onGeminiBack()
1630 if self.active then
1631 self:goBack()
1632 return true
1635 function Client:onGeminiUnback()
1636 if self.active then
1637 self:goBack(-1)
1638 return true
1641 function Client:onGeminiHistory()
1642 if self.active then
1643 self:showHistoryMenu()
1644 return true
1647 function Client:onGeminiBookmarks()
1648 self:openUrl("about:bookmarks")
1649 return true
1651 function Client:onGeminiMark()
1652 if self.active then
1653 self:addMarkInteractive(gemini.showUrl(self:purl()))
1654 return true
1657 function Client:onGeminiNext()
1658 self:openQueueItem()
1659 return true
1661 function Client:onGeminiAdd()
1662 if self.active then
1663 self:queueCachedHistoryItem(self.history[1])
1664 return true
1667 function Client:onGeminiInput()
1668 if self.active then
1669 self:userPromptInput(self.history[1].purl)
1670 return true
1673 function Client:onGeminiReload()
1674 if self.active then
1675 self:openUrl(self.history[1].purl, { replace_history = true })
1676 return true
1679 function Client:onGeminiUp()
1680 if self.active then
1681 local u = gemini.showUrl(self:purl())
1682 local up = gemini.upUrl(u)
1683 if up ~= u then
1684 self:openUrl(up)
1686 return true
1689 function Client:onGeminiGoNew()
1690 self:showNav("")
1691 return true
1693 function Client:onGeminiNav()
1694 self:showNav()
1695 return true
1698 function Client:addToMainMenu(menu_items)
1699 menu_items.gemini = {
1700 sorting_hint = "search",
1701 text = _("Browse Gemini"),
1702 callback = function()
1703 self:onBrowseGemini()
1704 end,
1706 local hint = "search_settings"
1707 if Version:getNormalizedCurrentVersion() < 202305180000 then
1708 -- backwards compatibility
1709 hint = "search"
1711 menu_items.gemini_settings = {
1712 text = _("Gemini settings"),
1713 sorting_hint = hint,
1714 sub_item_table = {
1716 text = _("Show help"),
1717 callback = function()
1718 self:openUrl("about:help")
1719 end,
1722 text = T(_("Max cached history items: %1"), max_cache_history_items),
1723 help_text = _("History items up to this limit will be stored on the filesystem and can be accessed offline with Back."),
1724 keep_menu_open = true,
1725 callback = function(touchmenu_instance)
1726 local widget = SpinWidget:new{
1727 title_text = _("Max cached history items"),
1728 value = max_cache_history_items,
1729 value_min = 0,
1730 value_max = 200,
1731 default_value = default_max_cache_history_items,
1732 callback = function(spin)
1733 max_cache_history_items = spin.value
1734 G_reader_settings:saveSetting("gemini_max_cache_history_items", spin.value)
1735 touchmenu_instance:updateItems()
1736 end,
1738 UIManager:show(widget)
1739 end,
1742 text = _("Set directory for saved documents"),
1743 keep_menu_open = true,
1744 callback = function()
1745 local title_header = _("Current directory for saved gemini documents:")
1746 local current_path = save_dir
1747 local default_path = getDefaultSavesDir()
1748 local function caller_callback(path)
1749 save_dir = path
1750 G_reader_settings:saveSetting("gemini_save_dir", path)
1751 if not util.pathExists(path) then
1752 lfs.mkdir(path)
1755 filemanagerutil.showChooseDialog(title_header, caller_callback, current_path, default_path)
1756 end,
1759 text = _("Configure scheme proxies"),
1760 help_text = _("Configure proxy servers to use for non-gemini URL schemes."),
1761 callback = function()
1762 SchemeProxies:edit()
1763 end,
1766 text = _("Disable fetch on add"),
1767 help_text = _("Disables immediately fetching URLs added to the queue when connected."),
1768 checked_func = function()
1769 return G_reader_settings:isTrue("gemini_no_fetch_on_add")
1770 end,
1771 callback = function()
1772 G_reader_settings:flipNilOrFalse("gemini_no_fetch_on_add")
1773 end,
1776 text = _("Next in queue on end"),
1777 help_text = _("Makes tapping at the end of the document load the next queue item (if any)."),
1778 checked_func = function()
1779 return G_reader_settings:isTrue("gemini_next_on_end")
1780 end,
1781 callback = function()
1782 G_reader_settings:flipNilOrFalse("gemini_next_on_end")
1783 end,
1786 text = _("Confirm certificates for new hosts"),
1787 help_text = _("Overrides the default behaviour of silently trusting the first server identity seen for a host, allowing you to confirm the certificate hash out-of-band."),
1788 checked_func = function()
1789 return G_reader_settings:isTrue("gemini_confirm_tofu")
1790 end,
1791 callback = function()
1792 G_reader_settings:flipNilOrFalse("gemini_confirm_tofu")
1793 end,
1799 return Client