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
51 return dir
.. "downloaded"
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
64 repl_purl
= nil, -- parsed URL to request input at after init
70 max_input_history
= 10,
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
88 function Client
:init(ui
)
90 self
.active
= self
.activated
91 self
.activated
= false
94 local postcb
= self
.ui
.registerPostReaderReadyCallback
or
95 self
.ui
.registerPostReadyCallback
96 -- Name for the callback in versions <= 2024.04
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
)
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)
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
)
123 self
.ui
.statistics
= 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
)
134 self
.ui
.link
:registerScheme("")
137 self
.ui
.link
:addToExternalLinkDialog("23_gemini", function(this
, link_url
)
139 text
= _("Open via Gemini"),
140 callback
= function()
141 UIManager
:close(this
.external_link_dialog
)
142 this
.ui
:handleEvent(Event
:new("FollowGeminiLink", link_url
))
144 show_in_dialog_func
= function(u
)
145 local scheme
= u
:match("^(%w[%w+%-.]*):") or ""
146 if scheme
== "" and self
.active
then
149 return util
.arrayContains(SchemeProxies
:supportedSchemes(), scheme
)
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
)
160 text
= _("Add links to queue"),
161 show_in_highlight_dialog_func
= function()
164 callback
= function()
165 self
:queueLinksInSelected(this
.selected_text
)
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",
182 ratio_x
= 0, ratio_y
= 0,
183 ratio_w
= 1, ratio_h
= 1,
186 -- Tap on gemini links has priority over everything
188 "readerhighlight_tap",
189 "tap_top_left_corner",
190 "tap_top_right_corner",
191 "tap_left_bottom_corner",
192 "tap_right_bottom_corner",
194 "readerconfigmenu_ext_tap",
195 "readerconfigmenu_tap",
196 "readermenu_ext_tap",
201 handler
= function(ges
) return self
:onTap(nil, ges
) end,
204 id
= "hold_release_link_gemini",
205 ges
= "hold_release",
207 ratio_x
= 0, ratio_y
= 0,
208 ratio_w
= 1, ratio_h
= 1,
211 "readerhighlight_hold_release",
213 handler
= function(ges
) return self
:onHoldRelease(nil, ges
) end,
216 id
= "hold_pan_link_gemini",
219 ratio_x
= 0, ratio_y
= 0,
220 ratio_w
= 1, ratio_h
= 1,
223 "readerhighlight_hold_pan",
225 handler
= function(ges
) return self
:onHoldPan(nil, ges
) end,
228 id
= "double_tap_link_gemini",
231 ratio_x
= 0, ratio_y
= 0,
232 ratio_w
= 1, ratio_h
= 1,
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,
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")
266 if type(body
) == "string" then
271 local chunk
, aborted
= body
:read(256)
272 while chunk
and chunk
~= "" do
274 chunk
, aborted
= body
:read(256)
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)
294 function Client
:openBody(body
, mimetype
, purl
, cert_info
, replace_history
)
295 util
.makePath(history_dir
)
297 local function get_ext(p
)
299 local ext
, m
= p
.path
:gsub(".*%.","",1)
300 if m
== 1 and ext
:match("^%w*$") then
305 local hn
= #self
.history
306 if replace_history
then
309 local tn
= history_dir
.. "/Gemini " .. hn
310 local ext
= self
:mimeToExt(mimetype
) or get_ext(purl
)
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
)
327 if not replace_history
then
328 -- Delete history tail
329 local ok
, iter
, dir_obj
= pcall(lfs
.dir
, history_dir
)
331 UIManager
:show(InfoMessage
:new
{text
= _("Failed to list history directory")})
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
349 while table.remove(self
.unhistory
) do end
352 if not writeBodyToFile(body
, tn
) then
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
360 table.insert(self
.history
, 1, history_item
)
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)
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
)
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")
409 f
:write(require("staticgemtexts").default_bookmarks
)
415 function Client
:openUrl(article_url
, opts
)
416 if type(article_url
) ~= "string" then
417 article_url
= url
.build(article_url
)
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
)
424 article_url
= url
.absolute(self
:purl(), article_url
)
427 local function fail(msg
)
428 UIManager
:show(InfoMessage
:new
{
430 dismiss_callback
= opts
.after_err_cb
,
434 local purl
= url
.parse(article_url
, {port
= "1965"})
436 if purl
and purl
.scheme
== "about" then
438 if purl
.path
== "bookmarks" then
439 self
:writeDefaultBookmarks()
440 G_reader_settings
:makeTrue("gemini_initiated")
441 body
= io
.open(marks_path
, "r")
443 body
= require("staticgemtexts")[purl
.path
]
446 body_cb(body
, "text/gemini", purl
)
448 fail(T(_("Unknown \"about:\" URL: %1"), article_url
))
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")
465 return body_cb(body
, nil, purl
)
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
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
)
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")
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
)
521 if meta
and #meta
> 0 then
522 msg
= T(_("Server reports %1: %2"), msg
, meta
)
524 msg
= T(_("Server reports %1"), msg
)
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."))
534 local new_uri
= url
.absolute(purl
, meta
)
535 local pnew
= url
.parse(new_uri
)
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
{
545 ok_text
= _("Follow"),
546 cancel_text
= _("Cancel"),
547 ok_callback
= function()
548 self
:openUrl(new_uri
, opts
)
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
{
566 ok_text
= _("Set identity"),
567 cancel_text
= _("Cancel"),
568 ok_callback
= function()
569 Identities
:confAt(gemini
.showUrl(purl
), function(new_id
)
571 self
:openUrl(purl
, opts
)
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
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
606 G_reader_settings
:isTrue("gemini_confirm_tofu"),
607 proxy
and proxy
.host
,
613 -- Prompt user for input. May modify purl.
614 function Client
:promptInput(purl
, prompt
, is_secret
, repl
, initial
, openUrl_opts
)
617 local multiline_button
618 local function submit()
619 local input
= input_dialog
:getInputText()
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) })
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
)
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
)
651 latest_input
= input_dialog
:getInputText()
654 input_dialog
:setInputText(hi
> 0 and self
.input_history
[hi
] or latest_input
, nil, false)
657 input_dialog
= InputDialog
:new
{
659 text_type
= is_secret
and "password",
660 enter_callback
= submit
,
666 callback
= function()
667 UIManager
:close(input_dialog
)
673 callback
= function() to_hist(hi
+1) end,
674 hold_callback
= function() to_hist(#self
.input_history
) end
679 callback
= function() to_hist(hi
-1) end,
680 hold_callback
= function() to_hist(0) end
690 input_dialog
:setInputText(initial
, nil, false)
694 -- read-eval-print-loop mode: keep presenting input dialog
695 repl_button
= CheckButton
:new
{
698 parent
= input_dialog
,
700 multiline_button
= CheckButton
:new
{
701 text
= _("Multiline input"),
703 callback
= function()
704 if input_dialog
.setAllowNewline
then
705 input_dialog
:setAllowNewline(multiline_button
.checked
)
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
712 input_dialog
._input_widget
.enter_callback
= submit
716 parent
= input_dialog
,
718 input_dialog
:addWidget(multiline_button
)
719 input_dialog
:addWidget(repl_button
)
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()
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
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
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()
755 choice2_text
= _("Connect without trust"),
756 choice2_callback
= function()
758 self
.trust_overrides
[new_fp
] = os
.time() + 3600
761 cancel_callback
= function()
765 UIManager
:show(widget
)
768 function Client
:goBack(n
)
770 if n
> #self
.history
-1 then
772 elseif n
< -#self
.unhistory
then
779 table.insert(self
.unhistory
, 1, table.remove(self
.history
, 1))
783 table.insert(self
.history
, 1, table.remove(self
.unhistory
, 1))
790 function Client
:clearHistory()
791 local function delete_item(item
)
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
)
807 return self
:followGesLink(ges
)
811 function Client
:onDoubleTap(_
, ges
)
813 return self
:followGesLink(ges
, true)
817 function Client
:onHoldPan(_
, ges
)
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()
830 self
.hold_pan
= false
833 function Client
:onSwipe(_
, ges
)
835 local direction
= BD
.flipDirectionIfMirroredUILayout(ges
.direction
)
836 if direction
== "south" then
838 elseif direction
== "north" then
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
852 self
:showNav(link
.xpointer
)
854 self
:openUrl(link
.xpointer
)
861 function Client
:onFollowGeminiLink(u
)
862 return self
:showNav(u
)
865 function Client
:onEndOfBook()
867 if G_reader_settings
:isTrue("gemini_next_on_end") and #self
.queue
> 0 then
876 function Client
:queueLinksInSelected(selected
)
877 local html
= self
.ui
.document
:getHTMLFromXPointers(selected
.pos0
, selected
.pos1
, nil, true)
879 -- Following pattern isn't strictly correct in general,
880 -- but is for the html generated from a gemini document.
882 for u
in html
:gmatch('<a[^>]*href="([^"]*)"') do
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
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")
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
)
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
)
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
)
947 table.insert(self
.queue
, 1, item
)
949 table.insert(self
.queue
, item
)
951 queue_persist
:save(self
.queue
)
954 function Client
:openQueueItem(n
)
956 local item
= self
.queue
[n
]
959 local f
= io
.open(item
.path
, "r")
961 UIManager
:show(InfoMessage
:new
{text
= T(_("Failed to open %1 for reading."), item
.path
)})
963 self
:openBody(f
, item
.mimetype
, url
.parse(item
.url
), item
.cert_info
)
964 FileManager
:deleteFile(item
.path
, true)
967 elseif item
.url
:match("^about:") or NetworkMgr
:isConnected() then
968 self
:openUrl(item
.url
)
971 UIManager
:show(InfoMessage
:new
{text
= T(_("Need network connection to fetch %1"), item
.url
)})
976 function Client
:popQueue(n
)
978 local item
= table.remove(self
.queue
, n
)
979 queue_persist
:save(self
.queue
)
983 function Client
:clearQueue()
984 while #self
.queue
> 0 do
985 local item
= table.remove(self
.queue
, 1)
987 FileManager
:deleteFile(item
.path
, true)
990 queue_persist
:save(self
.queue
)
993 function Client
:getSavePath(purl
, mimetype
, cb
)
995 local add_ext
= false
997 basename
= purl
.path
:gsub("/+$","",1):gsub(".*/","",1)
998 if basename
== "" and purl
.host
then
1002 if add_ext
or not basename
:match(".+%..+") then
1003 local ext
= self
:mimeToExt(mimetype
)
1005 basename
= basename
.."."..ext
1012 local function do_save()
1013 local fields
= widget
:getFields()
1014 local dir
= fields
[2]
1015 local bn
= fields
[1]
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
)
1032 UIManager
:close(widget
)
1039 widget
= MultiInputDialog
:new
{
1040 title
= _("Save as"),
1043 description
= _("Filename"),
1047 description
= _("Directory to save under"),
1056 callback
= function()
1057 UIManager
:close(widget
)
1062 is_enter_default
= true,
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)
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
1101 local f
= io
.open(marks_path
, "a")
1110 function Client
:addMarkInteractive(uri
)
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"),
1122 description
= _("Description (optional)"),
1125 description
= _("URL"),
1126 text
= gemini
.showUrl(uri
),
1134 callback
= function()
1135 UIManager
:close(widget
)
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
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()
1174 UIManager
:close(menu
)
1176 hold_callback
= function()
1177 UIManager
:close(menu
)
1178 self
:showNav(h
.purl
)
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()
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()
1201 UIManager
:close(menu
)
1208 title
= _("History"),
1209 item_table
= history_items
,
1210 onMenuHold
= function(_
, item
)
1211 if item
.hold_callback
then
1212 item
.hold_callback()
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]"
1231 function Client
:showCurrentInfo()
1232 local h
= self
.history
[1]
1234 { _("URL"), gemini
.showUrl(h
.purl
) },
1235 { _("Mimetype"), h
.mimetype
}
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
})
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")
1252 table.insert(kv_pairs
, { _("SHA256 digest"), cert_info
.fp
})
1253 table.insert(kv_pairs
, { _("Times seen"), cert_info
.trusted_times
})
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()
1265 widget
= KeyValuePage
:new
{
1266 title
= _("Page info"),
1267 kv_pairs
= kv_pairs
,
1269 UIManager
:show(widget
)
1272 function Client
:editQueue()
1275 local function show_queue_item(item
)
1276 return gemini
.showUrl(item
.url
) ..
1277 (item
.path
and " " .. _("(fetched)") or "")
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
)
1287 hold_callback
= function()
1288 UIManager
:close(menu
)
1289 self
:showNav(item
.url
)
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()
1301 UIManager
:close(menu
)
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()
1316 UIManager
:close(menu
)
1325 onMenuHold
= function(_
, item
)
1326 if item
.hold_callback
then
1327 item
.hold_callback()
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
== ""
1342 uri
= gemini
.showUrl(self
:purl())
1343 elseif self
:purl() and uri
~= "" then
1344 uri
= url
.absolute(self
:purl(), uri
)
1348 local advanced
= false
1349 local function current_nav_url()
1350 local u
= nav
:getInputText()
1351 if u
:match("^[./?]") then
1352 -- explicitly relative url
1354 u
= url
.absolute(self
:purl(), u
)
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
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
)
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
)
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
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,
1440 text_func
= function() return advanced
and _("Edit identity URLs") or _("Identity") end,
1442 callback
= function()
1443 close_nav_keyboard()
1444 Identities
:confAt(current_nav_url(), function()
1448 hold_callback
= function()
1449 close_nav_keyboard()
1454 text_func
= function() return advanced
and _("View as text") or _("Page info") end,
1456 callback
= function()
1457 UIManager
:close(nav
)
1458 self
:showCurrentInfo()
1460 hold_callback
= function()
1461 UIManager
:close(nav
)
1462 self
:viewCurrentAsText()
1468 text_func
= function() return advanced
and _("History") or _("Back") end,
1469 enabled
= #self
.history
> 1,
1470 callback
= function()
1471 UIManager
:close(nav
)
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
)
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()
1492 hold_callback
= function()
1493 UIManager
:close(nav
)
1498 text_func
= function() return advanced
and _("Edit marks") or _("Bookmarks") end,
1499 callback
= function()
1500 UIManager
:close(nav
)
1501 self
:openUrl("about:bookmarks")
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)
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.
1524 text_func
= function() return advanced
and _("Root") or _("Up") end,
1525 callback
= function()
1526 nav
:setInputText(gemini
.upUrl(current_nav_url()))
1529 hold_callback
= function()
1530 nav
:setInputText(url
.absolute(current_nav_url(), "/"))
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
)
1546 UIManager
:close(nav
)
1547 self
:openUrl(u
, { body_cb
= function(f
, mimetype
, p2
)
1548 self
:saveBody(f
, mimetype
, p2
)
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
)
1566 hold_callback
= function()
1567 if current_input_nonempty()
1568 and self
:addMark(current_nav_url()) then
1569 UIManager
:close(nav
)
1578 callback
= function()
1579 UIManager
:close(nav
)
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})
1590 hold_callback
= function()
1591 local purl
= url
.parse(current_nav_url())
1593 self
:userPromptInput(purl
)
1594 UIManager
:close(nav
)
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()
1612 nav
:onShowKeyboard()
1616 function Client
:onBrowseGemini()
1619 elseif #self
.history
> 0 then
1621 elseif G_reader_settings
:nilOrFalse("gemini_initiated") then
1622 self
:openUrl("about:welcome")
1624 self
:openUrl("about:bookmarks")
1629 function Client
:onGeminiBack()
1635 function Client
:onGeminiUnback()
1641 function Client
:onGeminiHistory()
1643 self
:showHistoryMenu()
1647 function Client
:onGeminiBookmarks()
1648 self
:openUrl("about:bookmarks")
1651 function Client
:onGeminiMark()
1653 self
:addMarkInteractive(gemini
.showUrl(self
:purl()))
1657 function Client
:onGeminiNext()
1658 self
:openQueueItem()
1661 function Client
:onGeminiAdd()
1663 self
:queueCachedHistoryItem(self
.history
[1])
1667 function Client
:onGeminiInput()
1669 self
:userPromptInput(self
.history
[1].purl
)
1673 function Client
:onGeminiReload()
1675 self
:openUrl(self
.history
[1].purl
, { replace_history
= true })
1679 function Client
:onGeminiUp()
1681 local u
= gemini
.showUrl(self
:purl())
1682 local up
= gemini
.upUrl(u
)
1689 function Client
:onGeminiGoNew()
1693 function Client
:onGeminiNav()
1698 function Client
:addToMainMenu(menu_items
)
1699 menu_items
.gemini
= {
1700 sorting_hint
= "search",
1701 text
= _("Browse Gemini"),
1702 callback
= function()
1703 self
:onBrowseGemini()
1706 local hint
= "search_settings"
1707 if Version
:getNormalizedCurrentVersion() < 202305180000 then
1708 -- backwards compatibility
1711 menu_items
.gemini_settings
= {
1712 text
= _("Gemini settings"),
1713 sorting_hint
= hint
,
1716 text
= _("Show help"),
1717 callback
= function()
1718 self
:openUrl("about:help")
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
,
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()
1738 UIManager
:show(widget
)
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
)
1750 G_reader_settings
:saveSetting("gemini_save_dir", path
)
1751 if not util
.pathExists(path
) then
1755 filemanagerutil
.showChooseDialog(title_header
, caller_callback
, current_path
, default_path
)
1759 text
= _("Configure scheme proxies"),
1760 help_text
= _("Configure proxy servers to use for non-gemini URL schemes."),
1761 callback
= function()
1762 SchemeProxies
:edit()
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")
1771 callback
= function()
1772 G_reader_settings
:flipNilOrFalse("gemini_no_fetch_on_add")
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")
1781 callback
= function()
1782 G_reader_settings
:flipNilOrFalse("gemini_next_on_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")
1791 callback
= function()
1792 G_reader_settings
:flipNilOrFalse("gemini_confirm_tofu")