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 writeBodyToFile(body
, tn
) then
325 local history_item
= { purl
= purl
, path
= tn
, mimetype
= mimetype
, cert_info
= cert_info
}
326 if replace_history
then
327 history
[1] = history_item
329 -- Delete history tail
330 local ok
, iter
, dir_obj
= pcall(lfs
.dir
, history_dir
)
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
>= #history
or n
<= #history
- max_cache_history_items
) and path
~= tn
then
341 FileManager
:deleteFile(path
, true)
342 local h
= history
[#history
- n
]
343 if h
and h
.path
== path
then
349 while table.remove(unhistory
) do end
351 table.insert(history
, 1, history_item
)
357 function GeminiPlugin
:openCurrent()
358 if not history
[1].path
then
359 return self
:openUrl(history
[1].purl
, { replace_history
= true })
362 -- as in ReaderUI:switchDocument, but with seamless option
363 local function switchDocumentSeamlessly(new_file
)
364 -- Mimic onShowingReader's refresh optimizations
365 self
.ui
.tearing_down
= true
366 self
.ui
.dithered
= nil
368 self
.ui
:handleEvent(Event
:new("CloseReaderMenu"))
369 self
.ui
:handleEvent(Event
:new("CloseConfigMenu"))
370 self
.ui
.highlight
:onClose() -- close highlight dialog if any
371 self
.ui
:onClose(false)
373 self
.ui
:showReader(new_file
, nil, true)
376 --self.ui:switchDocument(history[1].path)
377 local open_msg
= InfoMessage
:new
{
378 text
= T(_("%1\nOpening..."), gemini
.showUrl(history
[1].purl
, true)),
380 UIManager
:show(open_msg
)
382 -- Tell new GeminiPlugin instance to consider itself active
385 if self
.ui
.name
== "ReaderUI" then
386 --self.ui:switchDocument(history[1].path)
387 switchDocumentSeamlessly(history
[1].path
)
389 local ReaderUI
= require("apps/reader/readerui")
390 ReaderUI
:showReader(history
[1].path
, nil, true)
392 UIManager
:close(open_msg
)
395 local function normaliseIdentUrl(u
)
396 local purl
= url
.parse(u
, {scheme
= "gemini", port
= "1965"})
397 if purl
== nil or purl
.scheme
~= "gemini" then
401 return url
.build(purl
)
404 function GeminiPlugin
:setIdentity(at_url
, identity
)
405 local n
= normaliseIdentUrl(at_url
)
407 active_identities
[n
] = identity
408 active_identities_persist
:save(active_identities
)
412 -- Return first identity at or above at_url, if any
413 function GeminiPlugin
:getIdentity(at_url
)
414 local n
= normaliseIdentUrl(at_url
)
419 for u
,id
in pairs(active_identities
) do
425 local up
= url
.absolute(at_url
, "./")
427 up
= url
.absolute(at_url
, "../")
430 return self
:getIdentity(up
)
434 function GeminiPlugin
:writeDefaultBookmarks()
435 if not util
.fileExists(marks_path
) then
436 local f
= io
.open(marks_path
, "w")
438 f
:write(require("staticgemtexts").default_bookmarks
)
444 function GeminiPlugin
:openUrl(article_url
, opts
)
445 if type(article_url
) ~= "string" then
446 article_url
= url
.build(article_url
)
449 local bodyCallback
= opts
.bodyCallback
or function(f
, mimetype
, p
, cert_info
)
450 self
:openBody(f
, mimetype
, p
, cert_info
, opts
.replace_history
)
453 article_url
= url
.absolute(self
:purl(), article_url
)
456 local purl
= url
.parse(article_url
, {port
= "1965"})
458 if purl
and purl
.scheme
== "about" then
460 if purl
.path
== "bookmarks" then
461 self
:writeDefaultBookmarks()
462 body
= io
.open(marks_path
, "r")
464 body
= require("staticgemtexts")[purl
.path
]
467 bodyCallback(body
, "text/gemini", purl
)
469 UIManager
:show(InfoMessage
:new
{text
= _("Unknown \"about:\" URL: " .. article_url
)})
474 if not purl
or not purl
.host
or purl
.scheme
~= "gemini" then
475 UIManager
:show(InfoMessage
:new
{text
= _("Invalid gemini URL: " .. article_url
)})
479 -- FIXME: This seems not to work for some reason.
481 if NetworkMgr:willRerunWhenOnline(function() self:openUrl(article_url, opts) end) then
482 -- Not online yet, nothing more to do here, NetworkMgr will forward the callback and run it once connected!
487 if not NetworkMgr
:isOnline() then
488 UIManager
:show(InfoMessage
:new
{text
= "Can't connect to server: Not online."})
492 local id
= self
:getIdentity(article_url
)
493 local id_path
= id
and ids_dir
.."/"..id
494 local function success_cb(f
, p
, mimetype
, params
, cert_info
)
495 if opts
.repl_purl
then
496 repl_purl
= opts
.repl_purl
498 bodyCallback(f
, mimetype
, p
, cert_info
)
500 local function error_cb(msg
, p
, major
, minor
, server_msg
)
502 msg
= T(_("Server reports %1: %2"), msg
, server_msg
)
505 self
:promptInput(p
, server_msg
, minor
== "1")
506 elseif major
== "6" then
507 UIManager
:show(ConfirmBox
:new
{
509 ok_text
= _("Set identity"),
510 cancel_text
= _("Cancel"),
511 ok_callback
= function()
512 self
:confIdentAt(gemini
.showUrl(p
), function(new_id
)
514 self
:openUrl(p
, opts
)
520 UIManager
:show(InfoMessage
:new
{text
= msg
})
523 local function check_trust_cb(host
, new_fp
, old_trusted_times
, old_expiry
, cb
)
524 if trust_overrides
[new_fp
] and os
.time() < trust_overrides
[new_fp
] then
527 self
:promptUnexpectedCert(host
, new_fp
, old_trusted_times
, old_expiry
, cb
)
530 local function trust_modified_cb()
531 trust_store_persist
:save(trust_store
)
533 local perm_redir_cb
= nil -- TODO: automatically edit bookmarks file?
534 local function info_cb(msg
, fast
)
535 return Trapper
:info(msg
, fast
)
538 Trapper
:wrap(function()
539 Trapper
:setPausedText(T(_("Abort connection?")))
540 gemini
.makeRequest(gemini
.showUrl(purl
),
541 id
and id_path
..".key",
542 id
and id_path
..".crt",
543 nil, -- disable CA-based verification
551 G_reader_settings
:isTrue("gemini_confirm_tofu"))
556 -- Prompt user for input. May modify `purl.query`.
557 function GeminiPlugin
:promptInput(purl
, prompt
, is_secret
, repl
)
560 local multiline_button
561 local function submit()
562 purl
.query
= url
.escape(input_dialog
:getInputText())
563 if #url
.build(purl
) > 1024 then
564 UIManager
:show(InfoMessage
:new
{ text
=
565 T(_("Input too long (by %1 bytes)"), #url
.build(purl
) - 1024) })
567 UIManager
:close(input_dialog
)
568 self
:openUrl(purl
, { repl_purl
= repl_button
.checked
and purl
})
571 input_dialog
= InputDialog
:new
{
574 text_type
= is_secret
and "password",
575 enter_callback
= submit
,
581 callback
= function()
582 UIManager
:close(input_dialog
)
593 -- read-eval-print-loop mode: keep presenting input dialog
594 repl_button
= CheckButton
:new
{
597 parent
= input_dialog
,
599 multiline_button
= CheckButton
:new
{
600 text
= _("Multiline input"),
602 callback
= function()
603 input_dialog
.allow_newline
= multiline_button
.checked
604 -- FIXME: less hacky way to do this?
605 if multiline_button
.checked
then
606 input_dialog
._input_widget
.enter_callback
= nil
608 input_dialog
._input_widget
.enter_callback
= submit
611 parent
= input_dialog
,
613 input_dialog
:addWidget(multiline_button
)
614 input_dialog
:addWidget(repl_button
)
618 -- Draw just above keyboard (in vertical mode),
619 -- so we can see as much as possible of the newly loaded page
620 y_offset
= Screen
:scaleBySize(120)
621 if G_reader_settings
:isTrue("keyboard_key_compact") then
622 y_offset
= y_offset
+ 50
626 UIManager
:show(input_dialog
, nil, nil, nil, y_offset
)
627 input_dialog
:onShowKeyboard()
631 function GeminiPlugin
:promptUnexpectedCert(host
, new_fp
, old_trusted_times
, old_expiry
, cb
)
632 local widget
= MultiConfirmBox
:new
{
633 text
= old_trusted_times
> 0 and
635 The server identity presented by %1 does not match that previously trusted (%2 times).
636 Digest of received certificate: SHA256:%3
637 Previously trusted certificate expiry date: %4]]), host
, old_trusted_times
, new_fp
, old_expiry
) or
639 No trusted server identity known for %1. Trust provided server identity?
640 Digest of received certificate: SHA256:%2]]), host
, new_fp
),
641 face
= Font
:getFace("x_smallinfofont"),
642 choice1_text
= _("Trust new certificate"),
643 choice1_callback
= function()
646 choice2_text
= _("Connect without trust"),
647 choice2_callback
= function()
649 trust_overrides
[new_fp
] = os
.time() + 3600
652 cancel_callback
= function()
656 UIManager
:show(widget
)
659 function GeminiPlugin
:goBack(n
)
661 if n
> #history
-1 then
663 elseif n
< -#unhistory
then
670 table.insert(unhistory
, 1, table.remove(history
, 1))
674 table.insert(history
, 1, table.remove(unhistory
, 1))
681 function GeminiPlugin
:clearHistory()
682 local function delete_item(item
)
684 FileManager
:deleteFile(item
.path
, true)
688 while #history
> 1 do
689 delete_item(table.remove(history
, 2))
691 while #unhistory
> 0 do
692 delete_item(table.remove(unhistory
, 1))
696 function GeminiPlugin
:onTap(_
, ges
)
698 return self
:followGesLink(ges
)
702 function GeminiPlugin
:onDoubleTap(_
, ges
)
704 return self
:followGesLink(ges
, true)
708 function GeminiPlugin
:onHoldPan(_
, ges
)
712 function GeminiPlugin
:onHoldRelease(_
, ges
)
713 if self
.active
and not self
.hold_pan
then
714 return self
:followGesLink(ges
, true)
716 self
.hold_pan
= false
719 function GeminiPlugin
:onSwipe(_
, ges
)
721 local direction
= BD
.flipDirectionIfMirroredUILayout(ges
.direction
)
722 if direction
== "south" then
724 elseif direction
== "north" then
731 function GeminiPlugin
:followGesLink(ges
, nav
)
732 local link
= self
.ui
.link
:getLinkFromGes(ges
)
733 if link
and link
.xpointer
then
734 local scheme
= link
.xpointer
:match("^(%w[%w+%-.]*):") or ""
735 if scheme
== "gemini" or scheme
== "about"
736 or (scheme
== "" and self
.active
) then
738 self
:showNav(link
.xpointer
)
740 self
:openUrl(link
.xpointer
)
747 function GeminiPlugin
:onFollowGeminiLink(u
)
748 return self
:showNav(u
)
751 function GeminiPlugin
:queueLinksInSelected(selected
)
752 local html
= self
.ui
.document
:getHTMLFromXPointers(selected
.pos0
, selected
.pos1
, nil, true)
754 -- Following pattern isn't strictly correct in general,
755 -- but is for the html generated from a gemini document.
757 for u
in html
:gmatch('<a[^>]*href="([^"]*)"') do
761 UIManager
:show(InfoMessage
:new
{ text
=
762 n
== 0 and _("No links found in selected text.") or
763 T(_("Added %1 links to queue."), n
)
768 function GeminiPlugin
:queueBody(body
, u
, mimetype
, cert_info
, existing_item
, prepend
)
769 util
.makePath(queue_dir
)
770 local path
= queue_dir
.."/"..sha256(u
)
771 if writeBodyToFile(body
, path
) then
772 if existing_item
then
773 existing_item
.path
= path
774 existing_item
.mimetype
= mimetype
775 existing_item
.cert_info
= cert_info
777 self
:queueItem({ url
= u
, path
= path
, mimetype
= mimetype
, cert_info
= cert_info
}, prepend
)
779 elseif not existing_item
then
780 self
:queueItem({ url
= u
}, prepend
)
784 function GeminiPlugin
:queueCachedHistoryItem(h
, prepend
)
785 local body
= io
.open(h
.path
, "r")
787 self
:queueBody(body
, gemini
.showUrl(h
.purl
), h
.mimetype
, h
.cert_info
, nil, prepend
)
791 function GeminiPlugin
:fetchLink(u
, item
, prepend
)
792 self
:openUrl(u
, { bodyCallback
= function(body
, mimetype
, purl
, cert_info
)
793 self
:queueBody(body
, gemini
.showUrl(purl
), mimetype
, cert_info
, item
, prepend
)
797 function GeminiPlugin
:fetchQueue()
798 for _n
,item
in ipairs(queue
) do
799 if not item
.path
then
800 self
:fetchLink(item
.url
, item
)
805 function GeminiPlugin
:queueLink(u
, prepend
)
806 local purl
= url
.parse(u
)
807 if purl
and purl
.scheme
~= "about" and
808 not G_reader_settings
:isTrue("gemini_no_fetch_on_add") and NetworkMgr
:isOnline() then
809 self
:fetchLink(u
, nil, prepend
)
811 self
:queueItem({ url
= u
}, prepend
)
815 function GeminiPlugin
:queueItem(item
, prepend
)
816 for k
= #queue
,1,-1 do
817 if queue
[k
].url
== item
.url
then
818 table.remove(queue
,k
)
822 table.insert(queue
, 1, item
)
824 table.insert(queue
, item
)
826 queue_persist
:save(queue
)
829 function GeminiPlugin
:openQueueItem(n
)
831 local item
= queue
[n
]
834 local f
= io
.open(item
.path
, "r")
836 UIManager
:show(InfoMessage
:new
{text
= T(_("Failed to open %1 for reading."), item
.path
)})
838 self
:openBody(f
, item
.mimetype
, url
.parse(item
.url
), item
.cert_info
)
839 FileManager
:deleteFile(item
.path
, true)
842 elseif item
.url
:match("^about:") or NetworkMgr
:isOnline() then
843 self
:openUrl(item
.url
)
846 UIManager
:show(InfoMessage
:new
{text
= T(_("Need to be online to fetch %1"), item
.url
)})
851 function GeminiPlugin
:popQueue(n
)
853 local item
= table.remove(queue
, n
)
854 queue_persist
:save(queue
)
858 function GeminiPlugin
:clearQueue()
860 local item
= table.remove(queue
, 1)
862 FileManager
:deleteFile(item
.path
, true)
865 queue_persist
:save(queue
)
868 function GeminiPlugin
:getSavePath(purl
, mimetype
, cb
)
870 local add_ext
= false
872 basename
= purl
.path
:gsub("/+$","",1):gsub(".*/","",1)
873 if basename
== "" and purl
.host
then
877 if add_ext
or not basename
:match(".+%..+") then
878 local ext
= self
:mimeToExt(mimetype
)
880 basename
= basename
.."."..ext
887 local function do_save()
888 local fields
= widget
:getFields()
889 local dir
= fields
[2]
892 local path
= dir
.."/"..bn
893 local tp
= lfs
.attributes(path
, "mode")
894 if tp
== "directory" then
895 UIManager
:show(InfoMessage
:new
{text
= _("Path is a directory")})
896 elseif tp
~= nil then
897 UIManager
:show(ConfirmBox
:new
{
898 text
= _("File exists. Overwrite?"),
899 ok_text
= _("Overwrite"),
900 cancel_text
= _("Cancel"),
901 ok_callback
= function()
902 UIManager
:close(widget
)
907 UIManager
:close(widget
)
914 widget
= MultiInputDialog
:new
{
915 title
= _("Save as"),
918 description
= _("Filename"),
922 description
= _("Directory to save under"),
931 callback
= function()
932 UIManager
:close(widget
)
937 is_enter_default
= true,
943 widget
.input_field
[1].enter_callback
= do_save
944 widget
.input_field
[2].enter_callback
= do_save
945 UIManager
:show(widget
)
946 widget
:onShowKeyboard()
949 function GeminiPlugin
:saveCurrent()
950 self
:getSavePath(history
[1].purl
, history
[1].mimetype
, function(path
)
951 ffiutil
.copyFile(history
[1].path
, path
)
952 self
.ui
:saveSettings()
953 DocSettings
.updateLocation(history
[1].path
, path
, true)
957 function GeminiPlugin
:confIdentAt(uri
, cb
)
958 local n
= normaliseIdentUrl(uri
)
964 local id
= self
:getIdentity(n
)
965 local function set_id(new_id
)
967 self
:setIdentity(n
, new_id
)
969 UIManager
:close(widget
)
977 widget
= ButtonDialogTitle
:new
{
978 title
= T(_("Identity at %1"), gemini
.showUrl(n
)),
982 text
= id
and T(_("Stop using identity %1"), id
) or _("No identity in use"),
984 callback
= function()
987 local c_id
, at_url
= self
:getIdentity(n
)
989 self
:setIdentity(at_url
, nil)
994 UIManager
:close(widget
)
1005 callback
= function()
1006 UIManager
:close(widget
)
1010 text
= _("Choose identity"),
1011 callback
= function()
1012 self
:chooseIdentity(set_id
)
1016 text
= _("Create identity"),
1017 enabled
= os
.execute("openssl version >& /dev/null"),
1018 callback
= function()
1019 self
:createIdentInteractive(set_id
)
1025 UIManager
:show(widget
)
1028 function GeminiPlugin
:getIds()
1030 util
.findFiles(ids_dir
, function(path
,crt
)
1031 if crt
:find("%.crt$") then
1032 table.insert(ids
, crt
:sub(0,-5))
1039 function GeminiPlugin
:chooseIdentity(callback
)
1040 local ids
= self
:getIds()
1044 for _i
,id
in ipairs(ids
) do
1048 callback
= function()
1050 UIManager
:close(widget
)
1055 title
= _("Choose identity"),
1058 UIManager
:show(widget
)
1061 function GeminiPlugin
:createIdentInteractive(callback
)
1064 local function createId(id
, common_name
, rsa
)
1065 local path
= ids_dir
.."/"..id
1066 local shell_quoted_name
= common_name
:gsub("'","'\\''")
1067 local subj
= shell_quoted_name
== "" and "/" or "/CN="..shell_quoted_name
1069 os
.execute("openssl ecparam -genkey -name prime256v1 > "..path
..".key")
1070 os
.execute("openssl req -x509 -new -key "..path
..".key -sha256 -out "..path
..".crt -days 2000000 -subj '"..subj
.."'")
1072 os
.execute("openssl req -x509 -newkey rsa:2048 -keyout "..path
..".key -sha256 -out "..path
..".crt -days 2000000 -nodes -subj '"..subj
.."'")
1074 UIManager
:close(widget
)
1077 local function create_cb()
1078 local fields
= widget
:getFields()
1079 if fields
[1] == "" then
1080 UIManager
:show(InfoMessage
:new
{text
= _("Enter a petname for this identity, to be used in this client to refer to the identity.")})
1081 elseif not fields
[1]:match("^[%w_%-]+$") then
1082 UIManager
:show(InfoMessage
:new
{text
= _("Punctuation not allowed in petname.")})
1083 elseif fields
[1]:len() > 12 then
1084 UIManager
:show(InfoMessage
:new
{text
= _("Petname too long.")})
1085 elseif util
.fileExists(ids_dir
.."/"..fields
[1]..".crt") then
1086 UIManager
:show(ConfirmBox
:new
{
1087 text
= _("Identity already exists. Overwrite?"),
1088 ok_text
= _("Destroy existing identity"),
1089 cancel_text
= _("Cancel"),
1090 ok_callback
= function()
1091 createId(fields
[1], fields
[2], rsa_button
.checked
)
1095 createId(fields
[1], fields
[2])
1098 widget
= MultiInputDialog
:new
{
1099 title
= _("Create identity"),
1102 description
= _("Identity petname"),
1105 description
= _("Name (optional, sent to server)"),
1113 callback
= function()
1114 UIManager
:close(widget
)
1119 callback
= function()
1126 widget
.input_field
[1].enter_callback
= create_cb
1127 widget
.input_field
[2].enter_callback
= create_cb
1129 -- FIXME: Seems checkbuttons added to MultiInputDialog don't appear...
1130 rsa_button
= CheckButton
:new
{
1131 text
= _("Use RSA instead of ECDSA"),
1135 widget
:addWidget(rsa_button
)
1137 UIManager
:show(widget
)
1138 widget
:onShowKeyboard()
1141 function GeminiPlugin
:addMark(u
, desc
)
1142 if url
.parse(u
) then
1143 self
:writeDefaultBookmarks()
1144 local line
= "=> " .. u
1145 if desc
and desc
~= "" then
1146 line
= line
.. " " .. desc
1149 local f
= io
.open(marks_path
, "a")
1158 function GeminiPlugin
:addMarkInteractive(uri
)
1160 local function add_mark()
1161 local fields
= widget
:getFields()
1162 if self
:addMark(fields
[2], fields
[1]) then
1163 UIManager
:close(widget
)
1166 widget
= MultiInputDialog
:new
{
1167 title
= _("Add bookmark"),
1170 description
= _("Description (optional)"),
1173 description
= _("URL"),
1174 text
= gemini
.showUrl(uri
),
1182 callback
= function()
1183 UIManager
:close(widget
)
1188 is_enter_default
= true,
1189 callback
= add_mark
,
1194 widget
.input_field
[1].enter_callback
= add_mark
1195 widget
.input_field
[2].enter_callback
= add_mark
1196 UIManager
:show(widget
)
1197 widget
:onShowKeyboard()
1200 function GeminiPlugin
:showHistoryMenu(cb
)
1202 local history_items
= {}
1203 local function show_history_item(h
)
1204 return gemini
.showUrl(h
.purl
) ..
1205 (h
.path
and " " .. _("(fetched)") or "")
1207 for n
,h
in ipairs(history
) do
1208 table.insert(history_items
, {
1209 text
= T("%1 %2", n
-1, show_history_item(h
)),
1210 callback
= function()
1212 UIManager
:close(menu
)
1214 hold_callback
= function()
1215 UIManager
:close(menu
)
1216 self
:showNav(h
.purl
)
1220 for n
,h
in ipairs(unhistory
) do
1221 table.insert(history_items
, 1, {
1222 text
= T("%1 %2", -n
, show_history_item(h
)),
1223 callback
= function()
1225 UIManager
:close(menu
)
1229 if #history_items
> 1 then
1230 table.insert(history_items
, {
1231 text
= _("Clear all history"),
1232 callback
= function()
1233 UIManager
:show(ConfirmBox
:new
{
1234 text
= T(_("Clear %1 history items?"), #history_items
-1),
1235 ok_text
= _("Clear history"),
1236 cancel_text
= _("Cancel"),
1237 ok_callback
= function()
1239 UIManager
:close(menu
)
1246 title
= _("History"),
1247 item_table
= history_items
,
1248 onMenuHold
= function(_
, item
)
1249 if item
.hold_callback
then
1250 item
.hold_callback()
1254 UIManager
:show(menu
)
1257 function GeminiPlugin
:viewCurrentAsText()
1258 local h
= history
[1]
1259 local f
= io
.open(h
.path
,"r")
1260 UIManager
:show(TextViewer
:new
{
1261 title
= gemini
.showUrl(h
.purl
),
1262 text
= f
and f
:read("a") or "[Error reading file]"
1267 function GeminiPlugin
:showCurrentInfo()
1268 local h
= history
[1]
1270 { _("URL"), gemini
.showUrl(h
.purl
) },
1271 { _("Mimetype"), h
.mimetype
}
1275 table.insert(kv_pairs
, "----")
1276 local cert_info
= history
[1].cert_info
1277 if cert_info
.ca
then
1278 table.insert(kv_pairs
, { _("Trust type"), _("Chain to Certificate Authority") })
1279 for k
, v
in ipairs(cert_info
.ca
) do
1280 table.insert(kv_pairs
, { v
.name
, v
.value
})
1283 if cert_info
.trusted_times
> 0 then
1284 table.insert(kv_pairs
, { _("Trust type"), _("Trust On First Use"), callback
= function()
1285 UIManager
:close(widget
)
1286 self
:openUrl("about:tofu")
1288 table.insert(kv_pairs
, { _("SHA256 digest"), cert_info
.fp
})
1289 table.insert(kv_pairs
, { _("Times seen"), cert_info
.trusted_times
})
1291 table.insert(kv_pairs
, { _("Trust type"), _("Temporarily accepted") })
1292 table.insert(kv_pairs
, { _("SHA256 digest"), cert_info
.fp
})
1294 table.insert(kv_pairs
, { _("Expiry date"), cert_info
.expiry
})
1297 table.insert(kv_pairs
, "----")
1298 table.insert(kv_pairs
, { "Source", _("Select to view page as text"), callback
= function()
1299 self
:viewCurrentAsText()
1301 widget
= KeyValuePage
:new
{
1302 title
= _("Page info"),
1303 kv_pairs
= kv_pairs
,
1305 UIManager
:show(widget
)
1308 function GeminiPlugin
:editQueue()
1311 local function show_queue_item(item
)
1312 return gemini
.showUrl(item
.url
) ..
1313 (item
.path
and " " .. _("(fetched)") or "")
1316 for n
,item
in ipairs(queue
) do
1317 table.insert(items
, {
1318 text
= n
.. " " .. show_queue_item(item
),
1319 callback
= function()
1320 UIManager
:close(menu
)
1321 self
:openQueueItem(n
)
1323 hold_callback
= function()
1324 UIManager
:close(menu
)
1325 self
:showNav(item
.url
)
1328 if not item
.path
then
1329 unfetched
= unfetched
+ 1
1332 if unfetched
> 0 then
1333 table.insert(items
, {
1334 text
= T(_("Fetch %1 unfetched items"), unfetched
),
1335 callback
= function()
1337 UIManager
:close(menu
)
1343 table.insert(items
, {
1344 text
= _("Clear queue"),
1345 callback
= function()
1346 UIManager
:show(ConfirmBox
:new
{
1347 text
= T(_("Clear %1 items from queue?"), #queue
),
1348 ok_text
= _("Clear queue"),
1349 cancel_text
= _("Cancel"),
1350 ok_callback
= function()
1352 UIManager
:close(menu
)
1361 onMenuHold
= function(_
, item
)
1362 if item
.hold_callback
then
1363 item
.hold_callback()
1367 UIManager
:show(menu
)
1370 function GeminiPlugin
:editActiveIdentities()
1373 for u
,id
in pairs(active_identities
) do
1374 local show_u
= gemini
.showUrl(u
)
1375 table.insert(items
, {
1376 text
= T("%1: %2", id
, show_u
),
1377 callback
= function()
1378 UIManager
:show(ConfirmBox
:new
{
1379 text
= T(_("Stop using identity %1 at %2?"), id
, show_u
),
1380 ok_text
= _("Stop"),
1381 cancel_text
= _("Cancel"),
1382 ok_callback
= function()
1383 UIManager
:close(menu
)
1384 self
:setIdentity(u
, nil)
1385 self
:editActiveIdentities()
1391 table.sort(items
, function(i1
,i2
) return i1
.text
< i2
.text
end)
1393 title
= _("Active identities"),
1396 UIManager
:show(menu
)
1399 function GeminiPlugin
:showNav(uri
)
1401 uri
= gemini
.showUrl(self
:purl())
1402 elseif self
:purl() and uri
~= "" then
1403 uri
= url
.absolute(self
:purl(), uri
)
1407 local function current_nav_url()
1408 local u
= nav
:getInputText()
1409 if u
:match("^[./?]") then
1410 -- explicitly relative url
1412 u
= url
.absolute(self
:purl(), u
)
1415 -- absolutise if necessary
1416 local purl
= url
.parse(u
)
1417 if purl
and purl
.scheme
== nil and purl
.host
== nil then
1418 u
= "gemini://" .. u
1423 local function current_input_nonempty()
1424 local purl
= url
.parse(current_nav_url())
1425 return purl
and (purl
.host
or purl
.path
)
1427 local function show_hist()
1428 nav
:onCloseKeyboard()
1429 self
:showHistoryMenu(function(n
)
1430 UIManager
:close(nav
)
1434 local function queue_nav_url(prepend
)
1435 if current_input_nonempty() then
1436 local u
= current_nav_url()
1437 if u
== gemini
.showUrl(self
:purl()) and history
[1].path
then
1438 self
:queueCachedHistoryItem(history
[1], prepend
)
1440 self
:queueLink(u
, prepend
)
1442 UIManager
:close(nav
)
1445 local function update_buttons()
1446 local u
= current_nav_url()
1447 local purl
= url
.parse(u
)
1448 local id
= self
:getIdentity(u
)
1449 local text
= T(_("Identity: %1"), id
or _("[none]"))
1450 local id_button
= nav
.button_table
:getButtonById("ident")
1451 id_button
:setText(text
, id_button
.width
)
1452 id_button
:enableDisable(purl
and purl
.scheme
== "gemini" and purl
.host
~= "")
1453 UIManager
:setDirty(id_button
, "ui")
1455 local save_button
= nav
.button_table
:getButtonById("save")
1456 save_button
:enableDisable(purl
and purl
.scheme
and purl
.scheme
~= "about")
1457 UIManager
:setDirty(save_button
, "ui")
1459 local info_button
= nav
.button_table
:getButtonById("info")
1460 info_button
:enableDisable(u
== gemini
.showUrl(self
:purl()))
1461 UIManager
:setDirty(info_button
, "ui")
1464 nav
= InputDialog
:new
{
1465 title
= _("Gemini navigation"),
1466 width
= Screen
:scaleBySize(550), -- in pixels
1467 input_type
= "text",
1468 input
= uri
and gemini
.showUrl(uri
) or "gemini://",
1472 text
= _("Identity"),
1474 callback
= function()
1475 nav
:onCloseKeyboard()
1476 self
:confIdentAt(current_nav_url(), function()
1480 hold_callback
= function()
1481 nav
:onCloseKeyboard()
1482 self
:editActiveIdentities()
1486 text
= _("Page info"),
1488 callback
= function()
1489 UIManager
:close(nav
)
1490 self
:showCurrentInfo()
1492 hold_callback
= function()
1493 UIManager
:close(nav
)
1494 self
:viewCurrentAsText()
1501 enabled
= #history
> 1,
1502 callback
= function()
1503 UIManager
:close(nav
)
1506 hold_callback
= show_hist
,
1510 enabled
= #unhistory
> 0,
1511 callback
= function()
1512 UIManager
:close(nav
)
1515 hold_callback
= show_hist
,
1519 enabled
= #queue
> 0,
1520 callback
= function()
1521 UIManager
:close(nav
)
1522 self
:openQueueItem()
1524 hold_callback
= function()
1525 UIManager
:close(nav
)
1530 text
= _("Bookmarks"),
1531 callback
= function()
1532 UIManager
:close(nav
)
1533 self
:openUrl("about:bookmarks")
1535 hold_callback
= function()
1536 if self
.ui
.texteditor
then
1537 UIManager
:close(nav
)
1538 self
:writeDefaultBookmarks()
1539 local function done_cb()
1540 if self
:purl() and url
.build(self
:purl()) == "about:bookmarks" then
1541 self
:openUrl("about:bookmarks", { replace_history
= true })
1544 self
.ui
.texteditor
:quickEditFile(marks_path
, done_cb
, true)
1546 UIManager
:show(InfoMessage
:new
{text
=
1547 _("Can't edit bookmarks file: TextEditor plugin not loaded.")})
1555 callback
= function()
1556 local u
= current_nav_url()
1557 local up
= url
.absolute(u
, "./")
1559 up
= url
.absolute(u
, "../")
1561 nav
:setInputText(up
)
1564 hold_callback
= function()
1565 nav
:setInputText(url
.absolute(current_nav_url(), "/"))
1572 callback
= function()
1573 local u
= current_nav_url()
1574 local purl
= url
.parse(u
)
1575 if purl
and purl
.scheme
and purl
.scheme
== "about" then
1576 UIManager
:show(InfoMessage
:new
{text
= _("Can't save about: pages")})
1577 elseif u
== gemini
.showUrl(self
:purl()) then
1578 UIManager
:close(nav
)
1581 UIManager
:close(nav
)
1582 self
:openUrl(u
, { bodyCallback
= function(f
, mimetype
, p2
)
1583 self
:saveBody(f
, mimetype
, p2
)
1590 callback
= queue_nav_url
,
1591 hold_callback
= function() queue_nav_url(true) end,
1595 callback
= function()
1596 if current_input_nonempty() then
1597 self
:addMarkInteractive(current_nav_url())
1598 UIManager
:close(nav
)
1601 hold_callback
= function()
1602 if current_input_nonempty()
1603 and self
:addMark(current_nav_url()) then
1604 UIManager
:close(nav
)
1613 callback
= function()
1614 UIManager
:close(nav
)
1619 is_enter_default
= true,
1620 callback
= function()
1621 UIManager
:close(nav
)
1622 self
:openUrl(current_nav_url())
1624 hold_callback
= function()
1625 local purl
= url
.parse(current_nav_url())
1627 self
:promptInput(purl
, "[Input]")
1628 UIManager
:close(nav
)
1636 -- FIXME: less hacky way to do this?
1637 nav
._input_widget
.edit_callback
= function(edited
)
1645 nav
:onShowKeyboard()
1649 function GeminiPlugin
:onDispatcherRegisterActions()
1650 Dispatcher
:registerAction("browse_gemini", {category
= "none", event
= "BrowseGemini", title
= _("Browse Gemini"), general
= true, separator
= true })
1651 Dispatcher
:registerAction("gemini_back", {category
= "none", event
= "GeminiBack", title
= _("Gemini: Back"), reader
= true })
1652 Dispatcher
:registerAction("gemini_unback", {category
= "none", event
= "GeminiUnback", title
= _("Gemini: Unback"), reader
= true })
1653 Dispatcher
:registerAction("gemini_bookmarks", {category
= "none", event
= "GeminiBookmarks", title
= _("Gemini: Bookmarks"), reader
= true })
1654 Dispatcher
:registerAction("gemini_mark", {category
= "none", event
= "GeminiMark", title
= _("Gemini: Mark"), reader
= true })
1655 Dispatcher
:registerAction("gemini_next", {category
= "none", event
= "GeminiNext", title
= _("Gemini: Next"), reader
= true })
1656 Dispatcher
:registerAction("gemini_add", {category
= "none", event
= "GeminiAdd", title
= _("Gemini: Add"), reader
= true })
1657 Dispatcher
:registerAction("gemini_nav", {category
= "none", event
= "GeminiNav", title
= _("Gemini: Open nav"), reader
= true })
1658 Dispatcher
:registerAction("gemini_reload", {category
= "none", event
= "GeminiReload", title
= _("Gemini: Reload"), reader
= true })
1659 Dispatcher
:registerAction("gemini_up", {category
= "none", event
= "GeminiUp", title
= _("Gemini: Up"), reader
= true })
1660 Dispatcher
:registerAction("gemini_goNew", {category
= "none", event
= "GeminiGoNew", title
= _("Gemini: Enter URL"), reader
= true, separator
= true })
1663 function GeminiPlugin
:onBrowseGemini()
1666 elseif #history
> 0 then
1669 self
:openUrl("about:bookmarks")
1674 function GeminiPlugin
:onGeminiBack()
1680 function GeminiPlugin
:onGeminiUnback()
1686 function GeminiPlugin
:onGeminiBookmarks()
1687 self
:openUrl("about:bookmarks")
1690 function GeminiPlugin
:onGeminiMark()
1692 self
:addMarkInteractive(gemini
.showUrl(self
:purl()))
1696 function GeminiPlugin
:onGeminiNext()
1697 self
:openQueueItem()
1700 function GeminiPlugin
:onGeminiAdd()
1702 self
:queueCachedHistoryItem(history
[1])
1706 function GeminiPlugin
:onGeminiReload()
1708 self
:openUrl(history
[1].purl
, { replace_history
= true })
1712 function GeminiPlugin
:onGeminiUp()
1714 local u
= gemini
.showUrl(self
:purl())
1715 local up
= url
.absolute(u
, "./")
1717 up
= url
.absolute(u
, "../")
1725 function GeminiPlugin
:onGeminiGoNew()
1729 function GeminiPlugin
:onGeminiNav()
1734 function GeminiPlugin
:addToMainMenu(menu_items
)
1735 menu_items
.gemini
= {
1736 sorting_hint
= "search",
1737 text
= _("Browse Gemini"),
1738 callback
= function()
1739 self
:onBrowseGemini()
1742 local hint
= "search_settings"
1743 if Version
:getNormalizedCurrentVersion() < 202305180000 then
1744 -- backwards compatibility
1747 menu_items
.gemini_settings
= {
1748 text
= _("Gemini settings"),
1749 sorting_hint
= hint
,
1752 text
= _("Show help"),
1753 callback
= function()
1754 self
:openUrl("about:help")
1758 text
= T(_("Max cached history items: %1"), max_cache_history_items
),
1759 help_text
= _("History items up to this limit will be stored on the filesystem and can be accessed offline with Back."),
1760 keep_menu_open
= true,
1761 callback
= function(touchmenu_instance
)
1762 local widget
= SpinWidget
:new
{
1763 title_text
= _("Max cached history items"),
1764 value
= max_cache_history_items
,
1767 default_value
= default_max_cache_history_items
,
1768 callback
= function(spin
)
1769 max_cache_history_items
= spin
.value
1770 G_reader_settings
:saveSetting("gemini_max_cache_history_items", spin
.value
)
1771 touchmenu_instance
:updateItems()
1774 UIManager
:show(widget
)
1778 text
= _("Set directory for saved documents"),
1779 keep_menu_open
= true,
1780 callback
= function()
1781 local title_header
= _("Current directory for saved gemini documents:")
1782 local current_path
= save_dir
1783 local default_path
= getDefaultSavesDir()
1784 local function caller_callback(path
)
1786 G_reader_settings
:saveSetting("gemini_save_dir", path
)
1787 if not util
.pathExists(path
) then
1791 filemanagerutil
.showChooseDialog(title_header
, caller_callback
, current_path
, default_path
)
1795 text
= _("Disable fetch on add"),
1796 help_text
= _("Disables immediately fetching URLs added to the queue when online."),
1797 checked_func
= function()
1798 return G_reader_settings
:isTrue("gemini_no_fetch_on_add")
1800 callback
= function()
1801 G_reader_settings
:flipNilOrFalse("gemini_no_fetch_on_add")
1805 text
= _("Confirm certificates for new hosts"),
1806 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."),
1807 checked_func
= function()
1808 return G_reader_settings
:isTrue("gemini_confirm_tofu")
1810 callback
= function()
1811 G_reader_settings
:flipNilOrFalse("gemini_confirm_tofu")