1 local BD
= require("ui/bidi")
2 local WidgetContainer
= require("ui/widget/container/widgetcontainer")
3 local Event
= require("ui/event")
4 local Device
= require("device")
5 local UIManager
= require("ui/uimanager")
6 local ConfirmBox
= require("ui/widget/confirmbox")
7 local InputDialog
= require("ui/widget/inputdialog")
8 local MultiInputDialog
= require("ui/widget/multiinputdialog")
9 local FileManager
= require("apps/filemanager/filemanager")
10 local DocSettings
= require("docsettings")
11 local DataStorage
= require("datastorage")
12 local ReadHistory
= require("readhistory")
13 local Trapper
= require("ui/trapper")
14 local InfoMessage
= require("ui/widget/infomessage")
15 local ButtonDialogTitle
= require("ui/widget/buttondialogtitle")
16 local CheckButton
= require("ui/widget/checkbutton")
17 local MultiConfirmBox
= require("ui/widget/multiconfirmbox")
18 local SpinWidget
= require("ui/widget/spinwidget")
19 local Menu
= require("ui/widget/menu")
20 local Persist
= require("persist")
21 local NetworkMgr
= require("ui/network/manager")
22 local DocumentRegistry
= require("document/documentregistry")
23 local Font
= require("ui/font")
24 local Dispatcher
= require("dispatcher")
25 local TextViewer
= require("ui/widget/textviewer")
26 local KeyValuePage
= require("ui/widget/keyvaluepage")
27 local Screen
= require("device").screen
28 local Version
= require("version")
29 local filemanagerutil
= require("apps/filemanager/filemanagerutil")
30 local lfs
= require("libs/libkoreader-lfs")
31 local url
= require("socket.url")
32 local sha256
= require("ffi/sha2").sha256
33 local util
= require("util")
34 local ffiutil
= require("ffi/util")
35 local _
= require("gettext")
36 local T
= require("ffi/util").template
37 local gemini
= require("gemini")
39 local gemini_dir
= DataStorage
:getDataDir() .. "/gemini"
40 local ids_dir
= gemini_dir
.. "/identities"
41 local history_dir
= "/tmp/gemini-history"
42 local queue_dir
= "/tmp/gemini-queue"
43 local marks_path
= gemini_dir
.. "/bookmarks.gmi"
45 --local cafile_path = DataStorage:getDataDir() .. "/data/ca-bundle.crt"
47 local active_identities_persist
= Persist
:new
{ path
= gemini_dir
.. "/identities.lua" }
48 local queue_persist
= Persist
:new
{ path
= gemini_dir
.. "/queue.lua" }
49 local trust_store_persist
= Persist
:new
{ path
= gemini_dir
.. "/known_hosts.lua" }
50 local active_identities
= active_identities_persist
:load() or {}
51 local queue
= queue_persist
:load() or {}
52 local trust_store
= trust_store_persist
:load() or {}
56 local trust_overrides
= {}
58 local default_max_cache_history_items
= 20
59 local max_cache_history_items
= G_reader_settings
:readSetting("gemini_max_cache_history_items") or default_max_cache_history_items
61 local function getDefaultSavesDir()
62 local dir
= G_reader_settings
:readSetting("home_dir") or filemanagerutil
.getDefaultDir()
63 if dir
:sub(-1) ~= "/" then
66 return dir
.. "downloaded"
68 local save_dir
= G_reader_settings
:readSetting("gemini_save_dir") or getDefaultSavesDir()
70 local GeminiPlugin
= WidgetContainer
:extend
{
72 fullname
= _("Gemini plugin"),
77 -- Parsed URL of current item
78 function GeminiPlugin
:purl()
80 return history
[1].purl
84 local done_setup
= false
85 function GeminiPlugin
:setup()
86 util
.makePath(ids_dir
)
87 self
:onDispatcherRegisterActions()
88 if not done_setup
then
90 local GeminiDocument
= require("geminidocument")
91 DocumentRegistry
:addProvider("gmi", "text/gemini", GeminiDocument
, 100)
95 local activating
= false
97 function GeminiPlugin
:init()
100 self
.active
= activating
103 local postcb
= self
.ui
.registerPostReaderReadyCallback
105 -- Name for the callback in versions <= 2024.04
106 postcb
= self
.ui
.registerPostReadyCallback
109 if self
.ui
.document
.file
then
110 -- Keep gemini history files out of reader history
111 postcb(self
.ui
, function ()
112 ReadHistory
:removeItemByPath(self
.ui
.document
.file
)
116 local local_repl_purl
= repl_purl
117 postcb(self
.ui
, function ()
118 -- XXX: Input widget painted over without this delay. Better way?
119 UIManager
:scheduleIn(0.1, function ()
120 self
:promptInput(local_repl_purl
, "[Repeating]", false, true)
129 if self
.ui
and self
.ui
.link
then
130 if self
.ui
.link
.registerScheme
then
131 self
.ui
.link
:registerScheme("gemini")
132 self
.ui
.link
:registerScheme("about")
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 return scheme
== "gemini" or scheme
== "about" or
147 (scheme
== "" and self
.active
)
153 self
.ui
.menu
:registerToMainMenu(self
)
155 if self
.ui
and self
.ui
.highlight
then
156 self
.ui
.highlight
:addToHighlightDialog("20_queue_links", function(this
)
158 text
= _("Add links to queue"),
159 show_in_highlight_dialog_func
= function()
162 callback
= function()
163 self
:queueLinksInSelected(this
.selected_text
)
170 if Device
:isTouchDevice() then
171 self
.ui
:registerTouchZones({
173 id
= "tap_link_gemini",
176 ratio_x
= 0, ratio_y
= 0,
177 ratio_w
= 1, ratio_h
= 1,
180 -- Tap on gemini links has priority over everything
182 "readerhighlight_tap",
183 "tap_top_left_corner",
184 "tap_top_right_corner",
185 "tap_left_bottom_corner",
186 "tap_right_bottom_corner",
188 "readerconfigmenu_ext_tap",
189 "readerconfigmenu_tap",
190 "readermenu_ext_tap",
195 handler
= function(ges
) return self
:onTap(nil, ges
) end,
198 id
= "hold_release_link_gemini",
199 ges
= "hold_release",
201 ratio_x
= 0, ratio_y
= 0,
202 ratio_w
= 1, ratio_h
= 1,
205 "readerhighlight_hold_release",
207 handler
= function(ges
) return self
:onHoldRelease(nil, ges
) end,
210 id
= "hold_pan_link_gemini",
213 ratio_x
= 0, ratio_y
= 0,
214 ratio_w
= 1, ratio_h
= 1,
217 "readerhighlight_hold_pan",
219 handler
= function(ges
) return self
:onHoldPan(nil, ges
) end,
222 id
= "double_tap_link_gemini",
225 ratio_x
= 0, ratio_y
= 0,
226 ratio_w
= 1, ratio_h
= 1,
229 "double_tap_top_left_corner",
230 "double_tap_top_right_corner",
231 "double_tap_bottom_left_corner",
232 "double_tap_bottom_right_corner",
233 "double_tap_left_side",
234 "double_tap_right_side",
236 handler
= function(ges
) return self
:onDoubleTap(nil, ges
) end,
242 ratio_x
= 0, ratio_y
= 0,
243 ratio_w
= 1, ratio_h
= 1,
245 handler
= function(ges
) return self
:onSwipe(nil, ges
) end,
251 function GeminiPlugin
:mimeToExt(mimetype
)
252 return (mimetype
== "text/plain" and "txt")
253 or DocumentRegistry
:mimeToExt(mimetype
)
254 or (mimetype
:find("^text/") and "txt")
257 local function writeBodyToFile(body
, path
)
258 local o
= io
.open(path
, "w")
260 if type(body
) == "string" then
265 local chunk
, aborted
= body
:read(256)
266 while chunk
and chunk
~= "" do
268 chunk
, aborted
= body
:read(256)
279 function GeminiPlugin
:saveBody(body
, mimetype
, purl
)
280 self
:getSavePath(purl
, mimetype
, function(path
)
281 if not writeBodyToFile(body
, path
) then
282 -- clear up partial write
283 FileManager
:deleteFile(path
, true)
288 function GeminiPlugin
:openBody(body
, mimetype
, purl
, cert_info
, replace_history
)
289 util
.makePath(history_dir
)
291 local function get_ext(p
)
293 local ext
, m
= p
.path
:gsub(".*%.","",1)
300 if replace_history
then
303 local tn
= history_dir
.. "/Gemini " .. hn
304 local ext
= self
:mimeToExt(mimetype
) or get_ext(purl
)
306 tn
= tn
.. "." .. ext
309 if not DocumentRegistry
:hasProvider(tn
) then
310 UIManager
:show(ConfirmBox
:new
{
311 text
= T(_("Can't view file (%1). Save it instead?"), mimetype
),
312 ok_text
= _("Save file"),
313 cancel_text
= _("Cancel"),
314 ok_callback
= function()
315 self
:saveBody(body
, mimetype
, purl
)
321 if not replace_history
then
322 -- Delete history tail
323 local ok
, iter
, dir_obj
= pcall(lfs
.dir
, history_dir
)
328 for f
in iter
, dir_obj
do
329 local path
= history_dir
.."/"..f
330 local attr
= lfs
.attributes(path
) or {}
331 if attr
.mode
== "file" or attr
.mode
== "link" then
332 local n
= tonumber(f
:match("^Gemini (%d+)%f[^%d]"))
333 if n
and (n
>= #history
or n
<= #history
- max_cache_history_items
) then
334 FileManager
:deleteFile(path
, true)
335 local h
= history
[#history
- n
]
336 if h
and h
.path
== path
then
342 while table.remove(unhistory
) do end
345 if not writeBodyToFile(body
, tn
) then
349 local history_item
= { purl
= purl
, path
= tn
, mimetype
= mimetype
, cert_info
= cert_info
}
350 if replace_history
then
351 history
[1] = history_item
353 table.insert(history
, 1, history_item
)
359 function GeminiPlugin
:openCurrent()
360 if not history
[1].path
then
361 return self
:openUrl(history
[1].purl
, { replace_history
= true })
364 -- as in ReaderUI:switchDocument, but with seamless option
365 local function switchDocumentSeamlessly(new_file
)
366 -- Mimic onShowingReader's refresh optimizations
367 self
.ui
.tearing_down
= true
368 self
.ui
.dithered
= nil
370 self
.ui
:handleEvent(Event
:new("CloseReaderMenu"))
371 self
.ui
:handleEvent(Event
:new("CloseConfigMenu"))
372 self
.ui
.highlight
:onClose() -- close highlight dialog if any
373 self
.ui
:onClose(false)
375 self
.ui
:showReader(new_file
, nil, true)
378 --self.ui:switchDocument(history[1].path)
379 local open_msg
= InfoMessage
:new
{
380 text
= T(_("%1\nOpening..."), gemini
.showUrl(history
[1].purl
, true)),
382 UIManager
:show(open_msg
)
384 -- Tell new GeminiPlugin instance to consider itself active
387 if self
.ui
.name
== "ReaderUI" then
388 --self.ui:switchDocument(history[1].path)
389 switchDocumentSeamlessly(history
[1].path
)
391 local ReaderUI
= require("apps/reader/readerui")
392 ReaderUI
:showReader(history
[1].path
, nil, true)
394 UIManager
:close(open_msg
)
397 local function normaliseIdentUrl(u
)
398 local purl
= url
.parse(u
, {scheme
= "gemini", port
= "1965"})
399 if purl
== nil or purl
.scheme
~= "gemini" then
403 return url
.build(purl
)
406 function GeminiPlugin
:setIdentity(at_url
, identity
)
407 local n
= normaliseIdentUrl(at_url
)
409 active_identities
[n
] = identity
410 active_identities_persist
:save(active_identities
)
414 -- Return first identity at or above at_url, if any
415 function GeminiPlugin
:getIdentity(at_url
)
416 local n
= normaliseIdentUrl(at_url
)
421 for u
,id
in pairs(active_identities
) do
427 local up
= url
.absolute(at_url
, "./")
429 up
= url
.absolute(at_url
, "../")
432 return self
:getIdentity(up
)
436 function GeminiPlugin
:writeDefaultBookmarks()
437 if not util
.fileExists(marks_path
) then
438 local f
= io
.open(marks_path
, "w")
440 f
:write(require("staticgemtexts").default_bookmarks
)
446 function GeminiPlugin
:openUrl(article_url
, opts
)
447 if type(article_url
) ~= "string" then
448 article_url
= url
.build(article_url
)
451 local bodyCallback
= opts
.bodyCallback
or function(f
, mimetype
, p
, cert_info
)
452 self
:openBody(f
, mimetype
, p
, cert_info
, opts
.replace_history
)
455 article_url
= url
.absolute(self
:purl(), article_url
)
458 local purl
= url
.parse(article_url
, {port
= "1965"})
460 if purl
and purl
.scheme
== "about" then
462 if purl
.path
== "bookmarks" then
463 self
:writeDefaultBookmarks()
464 body
= io
.open(marks_path
, "r")
466 body
= require("staticgemtexts")[purl
.path
]
469 bodyCallback(body
, "text/gemini", purl
)
471 UIManager
:show(InfoMessage
:new
{text
= _("Unknown \"about:\" URL: " .. article_url
)})
476 if not purl
or not purl
.host
or purl
.scheme
~= "gemini" then
477 UIManager
:show(InfoMessage
:new
{text
= _("Invalid gemini URL: " .. article_url
)})
481 if NetworkMgr
:willRerunWhenConnected(function() self
:openUrl(article_url
, opts
) end) then
482 -- Not connected yet, nothing more to do here, NetworkMgr will forward the callback and run it once connected!
486 local id
= self
:getIdentity(article_url
)
487 local id_path
= id
and ids_dir
.."/"..id
488 local function success_cb(f
, p
, mimetype
, params
, cert_info
)
489 if opts
.repl_purl
then
490 repl_purl
= opts
.repl_purl
492 bodyCallback(f
, mimetype
, p
, cert_info
)
494 local function error_cb(msg
, p
, major
, minor
, server_msg
)
496 msg
= T(_("Server reports %1: %2"), msg
, server_msg
)
499 self
:promptInput(p
, server_msg
, minor
== "1")
500 elseif major
== "6" then
501 UIManager
:show(ConfirmBox
:new
{
503 ok_text
= _("Set identity"),
504 cancel_text
= _("Cancel"),
505 ok_callback
= function()
506 self
:confIdentAt(gemini
.showUrl(p
), function(new_id
)
508 self
:openUrl(p
, opts
)
514 UIManager
:show(InfoMessage
:new
{text
= msg
})
517 local function check_trust_cb(host
, new_fp
, old_trusted_times
, old_expiry
, cb
)
518 if trust_overrides
[new_fp
] and os
.time() < trust_overrides
[new_fp
] then
521 self
:promptUnexpectedCert(host
, new_fp
, old_trusted_times
, old_expiry
, cb
)
524 local function trust_modified_cb()
525 trust_store_persist
:save(trust_store
)
527 local perm_redir_cb
= nil -- TODO: automatically edit bookmarks file?
528 local function info_cb(msg
, fast
)
529 return Trapper
:info(msg
, fast
)
532 Trapper
:wrap(function()
533 Trapper
:setPausedText(T(_("Abort connection?")))
534 gemini
.makeRequest(gemini
.showUrl(purl
),
535 id
and id_path
..".key",
536 id
and id_path
..".crt",
537 nil, -- disable CA-based verification
545 G_reader_settings
:isTrue("gemini_confirm_tofu"))
550 -- Prompt user for input. May modify `purl.query`.
551 function GeminiPlugin
:promptInput(purl
, prompt
, is_secret
, repl
)
554 local multiline_button
555 local function submit()
556 purl
.query
= url
.escape(input_dialog
:getInputText())
557 if #url
.build(purl
) > 1024 then
558 UIManager
:show(InfoMessage
:new
{ text
=
559 T(_("Input too long (by %1 bytes)"), #url
.build(purl
) - 1024) })
561 UIManager
:close(input_dialog
)
562 self
:openUrl(purl
, { repl_purl
= repl_button
.checked
and purl
})
565 input_dialog
= InputDialog
:new
{
568 text_type
= is_secret
and "password",
569 enter_callback
= submit
,
575 callback
= function()
576 UIManager
:close(input_dialog
)
587 -- read-eval-print-loop mode: keep presenting input dialog
588 repl_button
= CheckButton
:new
{
591 parent
= input_dialog
,
593 multiline_button
= CheckButton
:new
{
594 text
= _("Multiline input"),
596 callback
= function()
597 input_dialog
.allow_newline
= multiline_button
.checked
598 -- FIXME: less hacky way to do this?
599 if multiline_button
.checked
then
600 input_dialog
._input_widget
.enter_callback
= nil
602 input_dialog
._input_widget
.enter_callback
= submit
605 parent
= input_dialog
,
607 input_dialog
:addWidget(multiline_button
)
608 input_dialog
:addWidget(repl_button
)
612 -- Draw just above keyboard (in vertical mode),
613 -- so we can see as much as possible of the newly loaded page
614 y_offset
= Screen
:scaleBySize(120)
615 if G_reader_settings
:isTrue("keyboard_key_compact") then
616 y_offset
= y_offset
+ 50
620 UIManager
:show(input_dialog
, nil, nil, nil, y_offset
)
621 input_dialog
:onShowKeyboard()
625 function GeminiPlugin
:promptUnexpectedCert(host
, new_fp
, old_trusted_times
, old_expiry
, cb
)
626 local widget
= MultiConfirmBox
:new
{
627 text
= old_trusted_times
> 0 and
629 The server identity presented by %1 does not match that previously trusted (%2 times).
630 Digest of received certificate: SHA256:%3
631 Previously trusted certificate expiry date: %4]]), host
, old_trusted_times
, new_fp
, old_expiry
) or
633 No trusted server identity known for %1. Trust provided server identity?
634 Digest of received certificate: SHA256:%2]]), host
, new_fp
),
635 face
= Font
:getFace("x_smallinfofont"),
636 choice1_text
= _("Trust new certificate"),
637 choice1_callback
= function()
640 choice2_text
= _("Connect without trust"),
641 choice2_callback
= function()
643 trust_overrides
[new_fp
] = os
.time() + 3600
646 cancel_callback
= function()
650 UIManager
:show(widget
)
653 function GeminiPlugin
:goBack(n
)
655 if n
> #history
-1 then
657 elseif n
< -#unhistory
then
664 table.insert(unhistory
, 1, table.remove(history
, 1))
668 table.insert(history
, 1, table.remove(unhistory
, 1))
675 function GeminiPlugin
:clearHistory()
676 local function delete_item(item
)
678 FileManager
:deleteFile(item
.path
, true)
682 while #history
> 1 do
683 delete_item(table.remove(history
, 2))
685 while #unhistory
> 0 do
686 delete_item(table.remove(unhistory
, 1))
690 function GeminiPlugin
:onTap(_
, ges
)
692 return self
:followGesLink(ges
)
696 function GeminiPlugin
:onDoubleTap(_
, ges
)
698 return self
:followGesLink(ges
, true)
702 function GeminiPlugin
:onHoldPan(_
, ges
)
706 function GeminiPlugin
:onHoldRelease(_
, ges
)
707 if self
.active
and not self
.hold_pan
then
708 if self
:followGesLink(ges
, true) then
709 if self
.ui
.highlight
then
710 self
.ui
.highlight
:clear()
715 self
.hold_pan
= false
718 function GeminiPlugin
:onSwipe(_
, ges
)
720 local direction
= BD
.flipDirectionIfMirroredUILayout(ges
.direction
)
721 if direction
== "south" then
723 elseif direction
== "north" then
730 function GeminiPlugin
:followGesLink(ges
, nav
)
731 local link
= self
.ui
.link
:getLinkFromGes(ges
)
732 if link
and link
.xpointer
then
733 local scheme
= link
.xpointer
:match("^(%w[%w+%-.]*):") or ""
734 if scheme
== "gemini" or scheme
== "about"
735 or (scheme
== "" and self
.active
) then
737 self
:showNav(link
.xpointer
)
739 self
:openUrl(link
.xpointer
)
746 function GeminiPlugin
:onFollowGeminiLink(u
)
747 return self
:showNav(u
)
750 function GeminiPlugin
:onEndOfBook()
751 -- TODO: seems we can't override the usual reader onEndOfBook handling.
752 -- Leaving this as a hidden option for now.
753 if G_reader_settings
:isTrue("gemini_next_on_end") then
754 if self
.active
and #queue
> 0 then
761 function GeminiPlugin
:queueLinksInSelected(selected
)
762 local html
= self
.ui
.document
:getHTMLFromXPointers(selected
.pos0
, selected
.pos1
, nil, true)
764 -- Following pattern isn't strictly correct in general,
765 -- but is for the html generated from a gemini document.
767 for u
in html
:gmatch('<a[^>]*href="([^"]*)"') do
771 UIManager
:show(InfoMessage
:new
{ text
=
772 n
== 0 and _("No links found in selected text.") or
773 T(_("Added %1 links to queue."), n
)
778 function GeminiPlugin
:queueBody(body
, u
, mimetype
, cert_info
, existing_item
, prepend
)
779 util
.makePath(queue_dir
)
780 local path
= queue_dir
.."/"..sha256(u
)
781 if writeBodyToFile(body
, path
) then
782 if existing_item
then
783 existing_item
.path
= path
784 existing_item
.mimetype
= mimetype
785 existing_item
.cert_info
= cert_info
787 self
:queueItem({ url
= u
, path
= path
, mimetype
= mimetype
, cert_info
= cert_info
}, prepend
)
789 elseif not existing_item
then
790 self
:queueItem({ url
= u
}, prepend
)
794 function GeminiPlugin
:queueCachedHistoryItem(h
, prepend
)
795 local body
= io
.open(h
.path
, "r")
797 self
:queueBody(body
, gemini
.showUrl(h
.purl
), h
.mimetype
, h
.cert_info
, nil, prepend
)
801 function GeminiPlugin
:fetchLink(u
, item
, prepend
)
802 self
:openUrl(u
, { bodyCallback
= function(body
, mimetype
, purl
, cert_info
)
803 self
:queueBody(body
, gemini
.showUrl(purl
), mimetype
, cert_info
, item
, prepend
)
807 function GeminiPlugin
:fetchQueue()
808 for _n
,item
in ipairs(queue
) do
809 if not item
.path
then
810 self
:fetchLink(item
.url
, item
)
815 function GeminiPlugin
:queueLink(u
, prepend
)
816 local purl
= url
.parse(u
)
817 if purl
and purl
.scheme
~= "about" and
818 not G_reader_settings
:isTrue("gemini_no_fetch_on_add") and NetworkMgr
:isConnected() then
819 self
:fetchLink(u
, nil, prepend
)
821 self
:queueItem({ url
= u
}, prepend
)
825 function GeminiPlugin
:queueItem(item
, prepend
)
826 for k
= #queue
,1,-1 do
827 if queue
[k
].url
== item
.url
then
828 table.remove(queue
,k
)
832 table.insert(queue
, 1, item
)
834 table.insert(queue
, item
)
836 queue_persist
:save(queue
)
839 function GeminiPlugin
:openQueueItem(n
)
841 local item
= queue
[n
]
844 local f
= io
.open(item
.path
, "r")
846 UIManager
:show(InfoMessage
:new
{text
= T(_("Failed to open %1 for reading."), item
.path
)})
848 self
:openBody(f
, item
.mimetype
, url
.parse(item
.url
), item
.cert_info
)
849 FileManager
:deleteFile(item
.path
, true)
852 elseif item
.url
:match("^about:") or NetworkMgr
:isConnected() then
853 self
:openUrl(item
.url
)
856 UIManager
:show(InfoMessage
:new
{text
= T(_("Need network connection to fetch %1"), item
.url
)})
861 function GeminiPlugin
:popQueue(n
)
863 local item
= table.remove(queue
, n
)
864 queue_persist
:save(queue
)
868 function GeminiPlugin
:clearQueue()
870 local item
= table.remove(queue
, 1)
872 FileManager
:deleteFile(item
.path
, true)
875 queue_persist
:save(queue
)
878 function GeminiPlugin
:getSavePath(purl
, mimetype
, cb
)
880 local add_ext
= false
882 basename
= purl
.path
:gsub("/+$","",1):gsub(".*/","",1)
883 if basename
== "" and purl
.host
then
887 if add_ext
or not basename
:match(".+%..+") then
888 local ext
= self
:mimeToExt(mimetype
)
890 basename
= basename
.."."..ext
897 local function do_save()
898 local fields
= widget
:getFields()
899 local dir
= fields
[2]
902 local path
= dir
.."/"..bn
903 local tp
= lfs
.attributes(path
, "mode")
904 if tp
== "directory" then
905 UIManager
:show(InfoMessage
:new
{text
= _("Path is a directory")})
906 elseif tp
~= nil then
907 UIManager
:show(ConfirmBox
:new
{
908 text
= _("File exists. Overwrite?"),
909 ok_text
= _("Overwrite"),
910 cancel_text
= _("Cancel"),
911 ok_callback
= function()
912 UIManager
:close(widget
)
917 UIManager
:close(widget
)
924 widget
= MultiInputDialog
:new
{
925 title
= _("Save as"),
928 description
= _("Filename"),
932 description
= _("Directory to save under"),
941 callback
= function()
942 UIManager
:close(widget
)
947 is_enter_default
= true,
953 widget
.input_field
[1].enter_callback
= do_save
954 widget
.input_field
[2].enter_callback
= do_save
955 UIManager
:show(widget
)
956 widget
:onShowKeyboard()
959 function GeminiPlugin
:saveCurrent()
960 self
:getSavePath(history
[1].purl
, history
[1].mimetype
, function(path
)
961 ffiutil
.copyFile(history
[1].path
, path
)
962 self
.ui
:saveSettings()
963 if DocSettings
.updateLocation
then
964 DocSettings
.updateLocation(history
[1].path
, path
, true)
969 function GeminiPlugin
:confIdentAt(uri
, cb
)
970 local n
= normaliseIdentUrl(uri
)
976 local id
= self
:getIdentity(n
)
977 local function set_id(new_id
)
979 self
:setIdentity(n
, new_id
)
981 UIManager
:close(widget
)
989 widget
= ButtonDialogTitle
:new
{
990 title
= T(_("Identity at %1"), gemini
.showUrl(n
)),
994 text
= id
and T(_("Stop using identity %1"), id
) or _("No identity in use"),
996 callback
= function()
999 local c_id
, at_url
= self
:getIdentity(n
)
1001 self
:setIdentity(at_url
, nil)
1006 UIManager
:close(widget
)
1017 callback
= function()
1018 UIManager
:close(widget
)
1022 text
= _("Choose identity"),
1023 callback
= function()
1024 self
:chooseIdentity(set_id
)
1028 text
= _("Create identity"),
1029 enabled
= os
.execute("openssl version >& /dev/null"),
1030 callback
= function()
1031 self
:createIdentInteractive(set_id
)
1037 UIManager
:show(widget
)
1040 function GeminiPlugin
:getIds()
1042 util
.findFiles(ids_dir
, function(path
,crt
)
1043 if crt
:find("%.crt$") then
1044 table.insert(ids
, crt
:sub(0,-5))
1051 function GeminiPlugin
:chooseIdentity(callback
)
1052 local ids
= self
:getIds()
1056 for _i
,id
in ipairs(ids
) do
1060 callback
= function()
1062 UIManager
:close(widget
)
1067 title
= _("Choose identity"),
1069 width
= Screen
:getWidth(), -- backwards compatibility;
1070 height
= Screen
:getHeight(), -- can delete for KOReader >= 2023.04
1072 UIManager
:show(widget
)
1075 function GeminiPlugin
:createIdentInteractive(callback
)
1078 local function createId(id
, common_name
, rsa
)
1079 local path
= ids_dir
.."/"..id
1080 local shell_quoted_name
= common_name
:gsub("'","'\\''")
1081 local subj
= shell_quoted_name
== "" and "/" or "/CN="..shell_quoted_name
1083 os
.execute("openssl ecparam -genkey -name prime256v1 > "..path
..".key")
1084 os
.execute("openssl req -x509 -new -key "..path
..".key -sha256 -out "..path
..".crt -days 2000000 -subj '"..subj
.."'")
1086 os
.execute("openssl req -x509 -newkey rsa:2048 -keyout "..path
..".key -sha256 -out "..path
..".crt -days 2000000 -nodes -subj '"..subj
.."'")
1088 UIManager
:close(widget
)
1091 local function create_cb()
1092 local fields
= widget
:getFields()
1093 if fields
[1] == "" then
1094 UIManager
:show(InfoMessage
:new
{text
= _("Enter a petname for this identity, to be used in this client to refer to the identity.")})
1095 elseif not fields
[1]:match("^[%w_%-]+$") then
1096 UIManager
:show(InfoMessage
:new
{text
= _("Punctuation not allowed in petname.")})
1097 elseif fields
[1]:len() > 12 then
1098 UIManager
:show(InfoMessage
:new
{text
= _("Petname too long.")})
1099 elseif util
.fileExists(ids_dir
.."/"..fields
[1]..".crt") then
1100 UIManager
:show(ConfirmBox
:new
{
1101 text
= _("Identity already exists. Overwrite?"),
1102 ok_text
= _("Destroy existing identity"),
1103 cancel_text
= _("Cancel"),
1104 ok_callback
= function()
1105 createId(fields
[1], fields
[2], rsa_button
.checked
)
1109 createId(fields
[1], fields
[2])
1112 widget
= MultiInputDialog
:new
{
1113 title
= _("Create identity"),
1116 description
= _("Identity petname"),
1119 description
= _("Name (optional, sent to server)"),
1127 callback
= function()
1128 UIManager
:close(widget
)
1133 callback
= function()
1140 widget
.input_field
[1].enter_callback
= create_cb
1141 widget
.input_field
[2].enter_callback
= create_cb
1143 -- FIXME: Seems checkbuttons added to MultiInputDialog don't appear...
1144 rsa_button
= CheckButton
:new
{
1145 text
= _("Use RSA instead of ECDSA"),
1149 widget
:addWidget(rsa_button
)
1151 UIManager
:show(widget
)
1152 widget
:onShowKeyboard()
1155 function GeminiPlugin
:addMark(u
, desc
)
1156 if url
.parse(u
) then
1157 self
:writeDefaultBookmarks()
1158 local line
= "=> " .. u
1159 if desc
and desc
~= "" then
1160 line
= line
.. " " .. desc
1163 local f
= io
.open(marks_path
, "a")
1172 function GeminiPlugin
:addMarkInteractive(uri
)
1174 local function add_mark()
1175 local fields
= widget
:getFields()
1176 if self
:addMark(fields
[2], fields
[1]) then
1177 UIManager
:close(widget
)
1180 widget
= MultiInputDialog
:new
{
1181 title
= _("Add bookmark"),
1184 description
= _("Description (optional)"),
1187 description
= _("URL"),
1188 text
= gemini
.showUrl(uri
),
1196 callback
= function()
1197 UIManager
:close(widget
)
1202 is_enter_default
= true,
1203 callback
= add_mark
,
1208 widget
.input_field
[1].enter_callback
= add_mark
1209 widget
.input_field
[2].enter_callback
= add_mark
1210 UIManager
:show(widget
)
1211 widget
:onShowKeyboard()
1214 function GeminiPlugin
:showHistoryMenu(cb
)
1215 cb
= cb
or function(n
) self
:goBack(n
) end
1217 local history_items
= {}
1218 local function show_history_item(h
)
1219 return gemini
.showUrl(h
.purl
) ..
1220 (h
.path
and " " .. _("(fetched)") or "")
1222 for n
,h
in ipairs(history
) do
1223 table.insert(history_items
, {
1224 text
= T("%1 %2", n
-1, show_history_item(h
)),
1225 callback
= function()
1227 UIManager
:close(menu
)
1229 hold_callback
= function()
1230 UIManager
:close(menu
)
1231 self
:showNav(h
.purl
)
1235 for n
,h
in ipairs(unhistory
) do
1236 table.insert(history_items
, 1, {
1237 text
= T("%1 %2", -n
, show_history_item(h
)),
1238 callback
= function()
1240 UIManager
:close(menu
)
1244 if #history_items
> 1 then
1245 table.insert(history_items
, {
1246 text
= _("Clear all history"),
1247 callback
= function()
1248 UIManager
:show(ConfirmBox
:new
{
1249 text
= T(_("Clear %1 history items?"), #history_items
-1),
1250 ok_text
= _("Clear history"),
1251 cancel_text
= _("Cancel"),
1252 ok_callback
= function()
1254 UIManager
:close(menu
)
1261 title
= _("History"),
1262 item_table
= history_items
,
1263 onMenuHold
= function(_
, item
)
1264 if item
.hold_callback
then
1265 item
.hold_callback()
1268 width
= Screen
:getWidth(), -- backwards compatibility;
1269 height
= Screen
:getHeight(), -- can delete for KOReader >= 2023.04
1271 UIManager
:show(menu
)
1274 function GeminiPlugin
:viewCurrentAsText()
1275 local h
= history
[1]
1276 local f
= io
.open(h
.path
,"r")
1277 UIManager
:show(TextViewer
:new
{
1278 title
= gemini
.showUrl(h
.purl
),
1279 text
= f
and f
:read("a") or "[Error reading file]"
1284 function GeminiPlugin
:showCurrentInfo()
1285 local h
= history
[1]
1287 { _("URL"), gemini
.showUrl(h
.purl
) },
1288 { _("Mimetype"), h
.mimetype
}
1292 table.insert(kv_pairs
, "----")
1293 local cert_info
= history
[1].cert_info
1294 if cert_info
.ca
then
1295 table.insert(kv_pairs
, { _("Trust type"), _("Chain to Certificate Authority") })
1296 for k
, v
in ipairs(cert_info
.ca
) do
1297 table.insert(kv_pairs
, { v
.name
, v
.value
})
1300 if cert_info
.trusted_times
> 0 then
1301 table.insert(kv_pairs
, { _("Trust type"), _("Trust On First Use"), callback
= function()
1302 UIManager
:close(widget
)
1303 self
:openUrl("about:tofu")
1305 table.insert(kv_pairs
, { _("SHA256 digest"), cert_info
.fp
})
1306 table.insert(kv_pairs
, { _("Times seen"), cert_info
.trusted_times
})
1308 table.insert(kv_pairs
, { _("Trust type"), _("Temporarily accepted") })
1309 table.insert(kv_pairs
, { _("SHA256 digest"), cert_info
.fp
})
1311 table.insert(kv_pairs
, { _("Expiry date"), cert_info
.expiry
})
1314 table.insert(kv_pairs
, "----")
1315 table.insert(kv_pairs
, { "Source", _("Select to view page as text"), callback
= function()
1316 self
:viewCurrentAsText()
1318 widget
= KeyValuePage
:new
{
1319 title
= _("Page info"),
1320 kv_pairs
= kv_pairs
,
1322 UIManager
:show(widget
)
1325 function GeminiPlugin
:editQueue()
1328 local function show_queue_item(item
)
1329 return gemini
.showUrl(item
.url
) ..
1330 (item
.path
and " " .. _("(fetched)") or "")
1333 for n
,item
in ipairs(queue
) do
1334 table.insert(items
, {
1335 text
= n
.. " " .. show_queue_item(item
),
1336 callback
= function()
1337 UIManager
:close(menu
)
1338 self
:openQueueItem(n
)
1340 hold_callback
= function()
1341 UIManager
:close(menu
)
1342 self
:showNav(item
.url
)
1345 if not item
.path
then
1346 unfetched
= unfetched
+ 1
1349 if unfetched
> 0 then
1350 table.insert(items
, {
1351 text
= T(_("Fetch %1 unfetched items"), unfetched
),
1352 callback
= function()
1354 UIManager
:close(menu
)
1360 table.insert(items
, {
1361 text
= _("Clear queue"),
1362 callback
= function()
1363 UIManager
:show(ConfirmBox
:new
{
1364 text
= T(_("Clear %1 items from queue?"), #queue
),
1365 ok_text
= _("Clear queue"),
1366 cancel_text
= _("Cancel"),
1367 ok_callback
= function()
1369 UIManager
:close(menu
)
1378 onMenuHold
= function(_
, item
)
1379 if item
.hold_callback
then
1380 item
.hold_callback()
1383 width
= Screen
:getWidth(), -- backwards compatibility;
1384 height
= Screen
:getHeight(), -- can delete for KOReader >= 2023.04
1386 UIManager
:show(menu
)
1389 function GeminiPlugin
:editActiveIdentities()
1392 for u
,id
in pairs(active_identities
) do
1393 local show_u
= gemini
.showUrl(u
)
1394 table.insert(items
, {
1395 text
= T("%1: %2", id
, show_u
),
1396 callback
= function()
1397 UIManager
:show(ConfirmBox
:new
{
1398 text
= T(_("Stop using identity %1 at %2?"), id
, show_u
),
1399 ok_text
= _("Stop"),
1400 cancel_text
= _("Cancel"),
1401 ok_callback
= function()
1402 UIManager
:close(menu
)
1403 self
:setIdentity(u
, nil)
1404 self
:editActiveIdentities()
1410 table.sort(items
, function(i1
,i2
) return i1
.text
< i2
.text
end)
1412 title
= _("Active identities"),
1414 width
= Screen
:getWidth(), -- backwards compatibility;
1415 height
= Screen
:getHeight(), -- can delete for KOReader >= 2023.04
1417 UIManager
:show(menu
)
1420 function GeminiPlugin
:showNav(uri
)
1421 if uri
and type(uri
) ~= "string" then
1422 uri
= url
.build(uri
)
1424 local showKbd
= not uri
or uri
== ""
1426 uri
= gemini
.showUrl(self
:purl())
1427 elseif self
:purl() and uri
~= "" then
1428 uri
= url
.absolute(self
:purl(), uri
)
1432 local advanced
= false
1433 local function current_nav_url()
1434 local u
= nav
:getInputText()
1435 if u
:match("^[./?]") then
1436 -- explicitly relative url
1438 u
= url
.absolute(self
:purl(), u
)
1441 -- absolutise if necessary
1442 local purl
= url
.parse(u
)
1443 if purl
and purl
.scheme
== nil and purl
.host
== nil then
1444 u
= "gemini://" .. u
1449 local function current_input_nonempty()
1450 local purl
= url
.parse(current_nav_url())
1451 return purl
and (purl
.host
or purl
.path
)
1453 local function close_nav_keyboard()
1454 if nav
.onCloseKeyboard
then
1455 nav
:onCloseKeyboard()
1456 elseif Version
:getNormalizedCurrentVersion() < 202309010000 then
1457 -- backwards compatibility
1458 if nav
._input_widget
.onCloseKeyboard
then
1459 nav
._input_widget
:onCloseKeyboard()
1463 local function show_hist()
1464 close_nav_keyboard()
1465 self
:showHistoryMenu(function(n
)
1466 UIManager
:close(nav
)
1470 local function queue_nav_url(prepend
)
1471 if current_input_nonempty() then
1472 local u
= current_nav_url()
1473 if u
== gemini
.showUrl(self
:purl()) and history
[1].path
then
1474 self
:queueCachedHistoryItem(history
[1], prepend
)
1476 self
:queueLink(u
, prepend
)
1478 UIManager
:close(nav
)
1481 local function update_buttons()
1482 local u
= current_nav_url()
1483 local purl
= url
.parse(u
)
1484 local id
= self
:getIdentity(u
)
1485 local text
= T(_("Identity: %1"), id
or _("[none]"))
1486 local id_button
= nav
.button_table
:getButtonById("ident")
1487 if not advanced
then
1488 id_button
:setText(text
, id_button
.width
)
1490 id_button
:enableDisable(advanced
or (purl
and purl
.scheme
== "gemini" and purl
.host
~= ""))
1491 UIManager
:setDirty(id_button
, "ui")
1493 local save_button
= nav
.button_table
:getButtonById("save")
1494 save_button
:enableDisable(purl
and purl
.scheme
and purl
.scheme
~= "about")
1495 UIManager
:setDirty(save_button
, "ui")
1497 local info_button
= nav
.button_table
:getButtonById("info")
1498 info_button
:enableDisable(u
== gemini
.showUrl(self
:purl()))
1499 UIManager
:setDirty(info_button
, "ui")
1501 local function toggle_advanced()
1502 advanced
= not advanced
1503 for _
,row
in ipairs(nav
.button_table
.buttons_layout
) do
1504 for _
,button
in ipairs(row
) do
1505 if button
.text_func
and button
.hold_callback
then
1506 button
:setText(button
.text_func(), button
.width
)
1507 button
.callback
, button
.hold_callback
= button
.hold_callback
, button
.callback
1512 UIManager
:setDirty(nav
, "ui")
1515 nav
= InputDialog
:new
{
1516 title
= _("Gemini navigation"),
1517 width
= Screen
:scaleBySize(550), -- in pixels
1518 input_type
= "text",
1519 input
= uri
and gemini
.showUrl(uri
) or "gemini://",
1523 text_func
= function() return advanced
and _("Edit identity URLs") or _("Identity") end,
1525 callback
= function()
1526 close_nav_keyboard()
1527 self
:confIdentAt(current_nav_url(), function()
1531 hold_callback
= function()
1532 close_nav_keyboard()
1533 self
:editActiveIdentities()
1537 text_func
= function() return advanced
and _("View as text") or _("Page info") end,
1539 callback
= function()
1540 UIManager
:close(nav
)
1541 self
:showCurrentInfo()
1543 hold_callback
= function()
1544 UIManager
:close(nav
)
1545 self
:viewCurrentAsText()
1551 text_func
= function() return advanced
and _("History") or _("Back") end,
1552 enabled
= #history
> 1,
1553 callback
= function()
1554 UIManager
:close(nav
)
1557 hold_callback
= show_hist
,
1560 text_func
= function() return advanced
and _("History") or _("Unback") end,
1561 enabled
= #unhistory
> 0,
1562 callback
= function()
1563 UIManager
:close(nav
)
1566 hold_callback
= show_hist
,
1569 text_func
= function() return advanced
and _("Edit queue") or _("Next") end,
1570 enabled
= #queue
> 0,
1571 callback
= function()
1572 UIManager
:close(nav
)
1573 self
:openQueueItem()
1575 hold_callback
= function()
1576 UIManager
:close(nav
)
1581 text_func
= function() return advanced
and _("Edit marks") or _("Bookmarks") end,
1582 callback
= function()
1583 UIManager
:close(nav
)
1584 self
:openUrl("about:bookmarks")
1586 hold_callback
= function()
1587 if self
.ui
.texteditor
and self
.ui
.texteditor
.quickEditFile
then
1588 UIManager
:close(nav
)
1589 self
:writeDefaultBookmarks()
1590 local function done_cb()
1591 if self
:purl() and url
.build(self
:purl()) == "about:bookmarks" then
1592 self
:openUrl("about:bookmarks", { replace_history
= true })
1595 self
.ui
.texteditor
:quickEditFile(marks_path
, done_cb
, true)
1597 UIManager
:show(InfoMessage
:new
{text
= T(_([[
1598 Can't load TextEditor: Plugin disabled or incompatible.
1599 To edit bookmarks, please edit the file %1 in the koreader directory manually.
1607 text_func
= function() return advanced
and _("Root") or _("Up") end,
1608 callback
= function()
1609 local u
= current_nav_url()
1610 local up
= url
.absolute(u
, "./")
1612 up
= url
.absolute(u
, "../")
1614 nav
:setInputText(up
)
1617 hold_callback
= function()
1618 nav
:setInputText(url
.absolute(current_nav_url(), "/"))
1625 callback
= function()
1626 local u
= current_nav_url()
1627 local purl
= url
.parse(u
)
1628 if purl
and purl
.scheme
and purl
.scheme
== "about" then
1629 UIManager
:show(InfoMessage
:new
{text
= _("Can't save about: pages")})
1630 elseif u
== gemini
.showUrl(self
:purl()) then
1631 UIManager
:close(nav
)
1634 UIManager
:close(nav
)
1635 self
:openUrl(u
, { bodyCallback
= function(f
, mimetype
, p2
)
1636 self
:saveBody(f
, mimetype
, p2
)
1642 text_func
= function() return advanced
and _("Prepend") or _("Add") end,
1643 callback
= queue_nav_url
,
1644 hold_callback
= function() queue_nav_url(true) end,
1647 text_func
= function() return advanced
and _("Quick mark") or _("Mark") end,
1648 callback
= function()
1649 if current_input_nonempty() then
1650 self
:addMarkInteractive(current_nav_url())
1651 UIManager
:close(nav
)
1654 hold_callback
= function()
1655 if current_input_nonempty()
1656 and self
:addMark(current_nav_url()) then
1657 UIManager
:close(nav
)
1666 callback
= function()
1667 UIManager
:close(nav
)
1671 text_func
= function() return advanced
and _("Input") or _("Go") end,
1672 is_enter_default
= true,
1673 callback
= function()
1674 UIManager
:close(nav
)
1675 self
:openUrl(current_nav_url())
1677 hold_callback
= function()
1678 local purl
= url
.parse(current_nav_url())
1680 self
:promptInput(purl
, "[Input]")
1681 UIManager
:close(nav
)
1689 -- FIXME: less hacky way to do this?
1690 nav
._input_widget
.edit_callback
= function(edited
)
1696 nav
.title_bar
.right_icon
= "appbar.settings"
1697 nav
.title_bar
.right_icon_tap_callback
= toggle_advanced
1698 nav
.title_bar
:init()
1702 nav
:onShowKeyboard()
1706 function GeminiPlugin
:onDispatcherRegisterActions()
1707 Dispatcher
:registerAction("browse_gemini", {category
= "none", event
= "BrowseGemini", title
= _("Browse Gemini"), general
= true, separator
= true })
1708 Dispatcher
:registerAction("gemini_back", {category
= "none", event
= "GeminiBack", title
= _("Gemini: Back"), reader
= true })
1709 Dispatcher
:registerAction("gemini_unback", {category
= "none", event
= "GeminiUnback", title
= _("Gemini: Unback"), reader
= true })
1710 Dispatcher
:registerAction("gemini_history", {category
= "none", event
= "GeminiHistory", title
= _("Gemini: History"), reader
= true })
1711 Dispatcher
:registerAction("gemini_bookmarks", {category
= "none", event
= "GeminiBookmarks", title
= _("Gemini: Bookmarks"), reader
= true })
1712 Dispatcher
:registerAction("gemini_mark", {category
= "none", event
= "GeminiMark", title
= _("Gemini: Mark"), reader
= true })
1713 Dispatcher
:registerAction("gemini_next", {category
= "none", event
= "GeminiNext", title
= _("Gemini: Next"), reader
= true })
1714 Dispatcher
:registerAction("gemini_add", {category
= "none", event
= "GeminiAdd", title
= _("Gemini: Add"), reader
= true })
1715 Dispatcher
:registerAction("gemini_nav", {category
= "none", event
= "GeminiNav", title
= _("Gemini: Open nav"), reader
= true })
1716 Dispatcher
:registerAction("gemini_reload", {category
= "none", event
= "GeminiReload", title
= _("Gemini: Reload"), reader
= true })
1717 Dispatcher
:registerAction("gemini_up", {category
= "none", event
= "GeminiUp", title
= _("Gemini: Up"), reader
= true })
1718 Dispatcher
:registerAction("gemini_goNew", {category
= "none", event
= "GeminiGoNew", title
= _("Gemini: Enter URL"), reader
= true, separator
= true })
1721 function GeminiPlugin
:onBrowseGemini()
1724 elseif #history
> 0 then
1727 self
:openUrl("about:bookmarks")
1732 function GeminiPlugin
:onGeminiBack()
1738 function GeminiPlugin
:onGeminiUnback()
1744 function GeminiPlugin
:onGeminiHistory()
1746 self
:showHistoryMenu()
1750 function GeminiPlugin
:onGeminiBookmarks()
1751 self
:openUrl("about:bookmarks")
1754 function GeminiPlugin
:onGeminiMark()
1756 self
:addMarkInteractive(gemini
.showUrl(self
:purl()))
1760 function GeminiPlugin
:onGeminiNext()
1761 self
:openQueueItem()
1764 function GeminiPlugin
:onGeminiAdd()
1766 self
:queueCachedHistoryItem(history
[1])
1770 function GeminiPlugin
:onGeminiReload()
1772 self
:openUrl(history
[1].purl
, { replace_history
= true })
1776 function GeminiPlugin
:onGeminiUp()
1778 local u
= gemini
.showUrl(self
:purl())
1779 local up
= url
.absolute(u
, "./")
1781 up
= url
.absolute(u
, "../")
1789 function GeminiPlugin
:onGeminiGoNew()
1793 function GeminiPlugin
:onGeminiNav()
1798 function GeminiPlugin
:addToMainMenu(menu_items
)
1799 menu_items
.gemini
= {
1800 sorting_hint
= "search",
1801 text
= _("Browse Gemini"),
1802 callback
= function()
1803 self
:onBrowseGemini()
1806 local hint
= "search_settings"
1807 if Version
:getNormalizedCurrentVersion() < 202305180000 then
1808 -- backwards compatibility
1811 menu_items
.gemini_settings
= {
1812 text
= _("Gemini settings"),
1813 sorting_hint
= hint
,
1816 text
= _("Show help"),
1817 callback
= function()
1818 self
:openUrl("about:help")
1822 text
= T(_("Max cached history items: %1"), max_cache_history_items
),
1823 help_text
= _("History items up to this limit will be stored on the filesystem and can be accessed offline with Back."),
1824 keep_menu_open
= true,
1825 callback
= function(touchmenu_instance
)
1826 local widget
= SpinWidget
:new
{
1827 title_text
= _("Max cached history items"),
1828 value
= max_cache_history_items
,
1831 default_value
= default_max_cache_history_items
,
1832 callback
= function(spin
)
1833 max_cache_history_items
= spin
.value
1834 G_reader_settings
:saveSetting("gemini_max_cache_history_items", spin
.value
)
1835 touchmenu_instance
:updateItems()
1838 UIManager
:show(widget
)
1842 text
= _("Set directory for saved documents"),
1843 keep_menu_open
= true,
1844 callback
= function()
1845 local title_header
= _("Current directory for saved gemini documents:")
1846 local current_path
= save_dir
1847 local default_path
= getDefaultSavesDir()
1848 local function caller_callback(path
)
1850 G_reader_settings
:saveSetting("gemini_save_dir", path
)
1851 if not util
.pathExists(path
) then
1855 filemanagerutil
.showChooseDialog(title_header
, caller_callback
, current_path
, default_path
)
1859 text
= _("Disable fetch on add"),
1860 help_text
= _("Disables immediately fetching URLs added to the queue when connected."),
1861 checked_func
= function()
1862 return G_reader_settings
:isTrue("gemini_no_fetch_on_add")
1864 callback
= function()
1865 G_reader_settings
:flipNilOrFalse("gemini_no_fetch_on_add")
1869 text
= _("Confirm certificates for new hosts"),
1870 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."),
1871 checked_func
= function()
1872 return G_reader_settings
:isTrue("gemini_confirm_tofu")
1874 callback
= function()
1875 G_reader_settings
:flipNilOrFalse("gemini_confirm_tofu")