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)
117 if self
.ui
and self
.ui
.link
then
118 if self
.ui
.link
.registerScheme
then
119 for _
,scheme
in ipairs(SchemeProxies
:supportedSchemes()) do
120 self
.ui
.link
:registerScheme(scheme
)
123 self
.ui
.link
:registerScheme("")
126 self
.ui
.link
:addToExternalLinkDialog("23_gemini", function(this
, link_url
)
128 text
= _("Open via Gemini"),
129 callback
= function()
130 UIManager
:close(this
.external_link_dialog
)
131 this
.ui
:handleEvent(Event
:new("FollowGeminiLink", link_url
))
133 show_in_dialog_func
= function(u
)
134 local scheme
= u
:match("^(%w[%w+%-.]*):") or ""
135 if scheme
== "" and self
.active
then
138 return util
.arrayContains(SchemeProxies
:supportedSchemes(), scheme
)
144 self
.ui
.menu
:registerToMainMenu(self
)
146 if self
.ui
and self
.ui
.highlight
then
147 self
.ui
.highlight
:addToHighlightDialog("20_queue_links", function(this
)
149 text
= _("Add links to queue"),
150 show_in_highlight_dialog_func
= function()
153 callback
= function()
154 self
:queueLinksInSelected(this
.selected_text
)
161 if self
.active
and Device
:isTouchDevice() then
162 self
.ui
:registerTouchZones({
164 id
= "tap_link_gemini",
167 ratio_x
= 0, ratio_y
= 0,
168 ratio_w
= 1, ratio_h
= 1,
171 -- Tap on gemini links has priority over everything
173 "readerhighlight_tap",
174 "tap_top_left_corner",
175 "tap_top_right_corner",
176 "tap_left_bottom_corner",
177 "tap_right_bottom_corner",
179 "readerconfigmenu_ext_tap",
180 "readerconfigmenu_tap",
181 "readermenu_ext_tap",
186 handler
= function(ges
) return self
:onTap(nil, ges
) end,
189 id
= "hold_release_link_gemini",
190 ges
= "hold_release",
192 ratio_x
= 0, ratio_y
= 0,
193 ratio_w
= 1, ratio_h
= 1,
196 "readerhighlight_hold_release",
198 handler
= function(ges
) return self
:onHoldRelease(nil, ges
) end,
201 id
= "hold_pan_link_gemini",
204 ratio_x
= 0, ratio_y
= 0,
205 ratio_w
= 1, ratio_h
= 1,
208 "readerhighlight_hold_pan",
210 handler
= function(ges
) return self
:onHoldPan(nil, ges
) end,
213 id
= "double_tap_link_gemini",
216 ratio_x
= 0, ratio_y
= 0,
217 ratio_w
= 1, ratio_h
= 1,
220 "double_tap_top_left_corner",
221 "double_tap_top_right_corner",
222 "double_tap_bottom_left_corner",
223 "double_tap_bottom_right_corner",
224 "double_tap_left_side",
225 "double_tap_right_side",
227 handler
= function(ges
) return self
:onDoubleTap(nil, ges
) end,
233 ratio_x
= 0, ratio_y
= 0,
234 ratio_w
= 1, ratio_h
= 1,
236 handler
= function(ges
) return self
:onSwipe(nil, ges
) end,
242 function Client
:mimeToExt(mimetype
)
243 return (mimetype
== "text/plain" and "txt")
244 or DocumentRegistry
:mimeToExt(mimetype
)
245 or (mimetype
:find("^text/") and "txt")
248 local function writeBodyToFile(body
, path
)
249 local o
= io
.open(path
, "w")
251 if type(body
) == "string" then
256 local chunk
, aborted
= body
:read(256)
257 while chunk
and chunk
~= "" do
259 chunk
, aborted
= body
:read(256)
270 function Client
:saveBody(body
, mimetype
, purl
)
271 self
:getSavePath(purl
, mimetype
, function(path
)
272 if not writeBodyToFile(body
, path
) then
273 -- clear up partial write
274 FileManager
:deleteFile(path
, true)
279 function Client
:openBody(body
, mimetype
, purl
, cert_info
, replace_history
)
280 util
.makePath(history_dir
)
282 local function get_ext(p
)
284 local ext
, m
= p
.path
:gsub(".*%.","",1)
290 local hn
= #self
.history
291 if replace_history
then
294 local tn
= history_dir
.. "/Gemini " .. hn
295 local ext
= self
:mimeToExt(mimetype
) or get_ext(purl
)
297 tn
= tn
.. "." .. ext
300 if not DocumentRegistry
:hasProvider(tn
) then
301 UIManager
:show(ConfirmBox
:new
{
302 text
= T(_("Can't view file (%1). Save it instead?"), mimetype
),
303 ok_text
= _("Save file"),
304 cancel_text
= _("Cancel"),
305 ok_callback
= function()
306 self
:saveBody(body
, mimetype
, purl
)
312 if not replace_history
then
313 -- Delete history tail
314 local ok
, iter
, dir_obj
= pcall(lfs
.dir
, history_dir
)
319 for f
in iter
, dir_obj
do
320 local path
= history_dir
.."/"..f
321 local attr
= lfs
.attributes(path
) or {}
322 if attr
.mode
== "file" or attr
.mode
== "link" then
323 local n
= tonumber(f
:match("^Gemini (%d+)%f[^%d]"))
324 if n
and (n
>= #self
.history
or n
<= #self
.history
- max_cache_history_items
) then
325 FileManager
:deleteFile(path
, true)
326 local h
= self
.history
[#self
.history
- n
]
327 if h
and h
.path
== path
then
333 while table.remove(self
.unhistory
) do end
336 if not writeBodyToFile(body
, tn
) then
340 local history_item
= { purl
= purl
, path
= tn
, mimetype
= mimetype
, cert_info
= cert_info
}
341 if replace_history
then
342 self
.history
[1] = history_item
344 table.insert(self
.history
, 1, history_item
)
350 function Client
:openCurrent()
351 if not (self
.history
[1].path
and util
.fileExists(self
.history
[1].path
)) then
352 return self
:openUrl(self
.history
[1].purl
, { replace_history
= true })
355 -- as in ReaderUI:switchDocument, but with seamless option
356 local function switchDocumentSeamlessly(new_file
)
357 -- Mimic onShowingReader's refresh optimizations
358 self
.ui
.tearing_down
= true
359 self
.ui
.dithered
= nil
361 self
.ui
:handleEvent(Event
:new("CloseReaderMenu"))
362 self
.ui
:handleEvent(Event
:new("CloseConfigMenu"))
363 self
.ui
.highlight
:onClose() -- close highlight dialog if any
364 self
.ui
:onClose(false)
366 self
.ui
:showReader(new_file
, nil, true)
369 local open_msg
= InfoMessage
:new
{
370 text
= T(_("%1\nOpening..."), gemini
.showUrl(self
.history
[1].purl
, true)),
372 UIManager
:show(open_msg
)
374 self
.activated
= true
376 if self
.ui
.name
== "ReaderUI" then
377 --self.ui:switchDocument(history[1].path)
378 switchDocumentSeamlessly(self
.history
[1].path
)
380 local ReaderUI
= require("apps/reader/readerui")
381 ReaderUI
:showReader(self
.history
[1].path
, nil, true)
383 UIManager
:close(open_msg
)
386 function Client
:writeDefaultBookmarks()
387 if not util
.fileExists(marks_path
) then
388 local f
= io
.open(marks_path
, "w")
390 f
:write(require("staticgemtexts").default_bookmarks
)
396 function Client
:openUrl(article_url
, opts
)
397 if type(article_url
) ~= "string" then
398 article_url
= url
.build(article_url
)
401 local body_cb
= opts
.body_cb
or function(f
, mimetype
, p
, cert_info
)
402 self
:openBody(f
, mimetype
, p
, cert_info
, opts
.replace_history
)
404 local after_err_cb
= opts
.after_err_cb
or function() end
406 article_url
= url
.absolute(self
:purl(), article_url
)
409 local purl
= url
.parse(article_url
, {port
= "1965"})
411 if purl
and purl
.scheme
== "about" then
413 if purl
.path
== "bookmarks" then
414 self
:writeDefaultBookmarks()
415 G_reader_settings
:makeTrue("gemini_initiated")
416 body
= io
.open(marks_path
, "r")
418 body
= require("staticgemtexts")[purl
.path
]
421 body_cb(body
, "text/gemini", purl
)
423 UIManager
:show(InfoMessage
:new
{text
= T(_("Unknown \"about:\" URL: %1"), article_url
)})
428 if not purl
or not purl
.host
then
429 UIManager
:show(InfoMessage
:new
{text
= T(_("Invalid URL: %1"), article_url
)})
433 local proxy
= SchemeProxies
:get(purl
.scheme
)
434 if purl
.scheme
~= "gemini" and not proxy
then
435 UIManager
:show(InfoMessage
:new
{text
= T(_("No proxy configured for scheme: %1"), purl
.scheme
)})
439 if NetworkMgr
:willRerunWhenConnected(function() self
:openUrl(article_url
, opts
) end) then
440 -- Not connected yet, nothing more to do here, NetworkMgr will forward the callback and run it once connected!
444 local id
, __
, id_path
= Identities
:get(article_url
)
445 local function success_cb(f
, p
, mimetype
, params
, cert_info
)
446 if opts
.repl_purl
then
447 self
.repl_purl
= opts
.repl_purl
449 body_cb(f
, mimetype
, p
, cert_info
)
451 local function error_cb(msg
, p
, major
, minor
, server_msg
)
453 msg
= T(_("Server reports %1: %2"), msg
, server_msg
)
456 self
:promptInput(p
, server_msg
, minor
== "1")
457 elseif major
== "6" then
458 UIManager
:show(ConfirmBox
:new
{
460 ok_text
= _("Set identity"),
461 cancel_text
= _("Cancel"),
462 ok_callback
= function()
463 Identities
:confAt(gemini
.showUrl(p
), function(new_id
)
465 self
:openUrl(p
, opts
)
471 UIManager
:show(InfoMessage
:new
{
473 dismiss_callback
= after_err_cb
,
477 local function check_trust_cb(host
, new_fp
, old_trusted_times
, old_expiry
, cb
)
478 if self
.trust_overrides
[new_fp
] and os
.time() < self
.trust_overrides
[new_fp
] then
481 self
:promptUnexpectedCert(host
, new_fp
, old_trusted_times
, old_expiry
, cb
)
484 local function trust_modified_cb()
485 trust_store_persist
:save(self
.trust_store
)
487 local perm_redir_cb
= nil -- TODO: automatically edit bookmarks file?
488 local function info_cb(msg
, fast
)
489 return Trapper
:info(msg
, fast
)
492 Trapper
:wrap(function()
493 Trapper
:setPausedText(T(_("Abort connection?")))
494 gemini
.makeRequest(gemini
.showUrl(purl
),
495 id
and id_path
..".key",
496 id
and id_path
..".crt",
497 nil, -- disable CA-based verification
505 G_reader_settings
:isTrue("gemini_confirm_tofu"),
506 proxy
and proxy
.host
)
511 -- Prompt user for input. May modify `purl.query`.
512 function Client
:promptInput(purl
, prompt
, is_secret
, repl
, initial
)
515 local multiline_button
516 local function submit()
517 local input
= input_dialog
:getInputText()
518 purl
.query
= url
.escape(input
)
519 if #url
.build(purl
) > 1024 then
520 UIManager
:show(InfoMessage
:new
{ text
=
521 T(_("Input too long (by %1 bytes)"), #url
.build(purl
) - 1024) })
523 UIManager
:close(input_dialog
)
524 table.insert(self
.input_history
, 1, input
)
525 if #self
.input_history
> self
.max_input_history
then
526 table.remove(self
.input_history
, #self
.input_history
)
529 repl_purl
= repl_button
.checked
and purl
,
530 after_err_cb
= function() self
:promptInput(purl
, prompt
, is_secret
, repl
, input
) end,
536 local function update_buttons()
537 local prev_button
= input_dialog
.button_table
:getButtonById("prev")
538 prev_button
:enableDisable(#self
.input_history
> hi
)
539 UIManager
:setDirty(prev_button
, "ui")
540 local next_button
= input_dialog
.button_table
:getButtonById("next")
541 next_button
:enableDisable(hi
> 0)
542 UIManager
:setDirty(next_button
, "ui")
544 local function to_hist(i
)
546 latest_input
= input_dialog
:getInputText()
549 input_dialog
:setInputText(hi
> 0 and self
.input_history
[hi
] or latest_input
)
552 input_dialog
= InputDialog
:new
{
554 input
= initial
or "",
555 text_type
= is_secret
and "password",
556 enter_callback
= submit
,
562 callback
= function()
563 UIManager
:close(input_dialog
)
569 callback
= function() to_hist(hi
+1) end,
570 hold_callback
= function() to_hist(#self
.input_history
) end
575 callback
= function() to_hist(hi
-1) end,
576 hold_callback
= function() to_hist(0) end
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 Client
:userPromptInput(purl
)
626 self
:promptInput(purl
, "[Input]", false, false, url
.unescape(purl
.query
or ""))
629 function Client
:promptUnexpectedCert(host
, new_fp
, old_trusted_times
, old_expiry
, cb
)
630 local widget
= MultiConfirmBox
:new
{
631 text
= old_trusted_times
> 0 and
633 The server identity presented by %1 does not match that previously trusted (%2 times).
634 Digest of received certificate: SHA256:%3
635 Previously trusted certificate expiry date: %4]]), host
, old_trusted_times
, new_fp
, old_expiry
) or
637 No trusted server identity known for %1. Trust provided server identity?
638 Digest of received certificate: SHA256:%2]]), host
, new_fp
),
639 face
= Font
:getFace("x_smallinfofont"),
640 choice1_text
= _("Trust new certificate"),
641 choice1_callback
= function()
644 choice2_text
= _("Connect without trust"),
645 choice2_callback
= function()
647 self
.trust_overrides
[new_fp
] = os
.time() + 3600
650 cancel_callback
= function()
654 UIManager
:show(widget
)
657 function Client
:goBack(n
)
659 if n
> #self
.history
-1 then
661 elseif n
< -#self
.unhistory
then
668 table.insert(self
.unhistory
, 1, table.remove(self
.history
, 1))
672 table.insert(self
.history
, 1, table.remove(self
.unhistory
, 1))
679 function Client
:clearHistory()
680 local function delete_item(item
)
682 FileManager
:deleteFile(item
.path
, true)
686 while #self
.history
> 1 do
687 delete_item(table.remove(self
.history
, 2))
689 while #self
.unhistory
> 0 do
690 delete_item(table.remove(self
.unhistory
, 1))
694 function Client
:onTap(_
, ges
)
696 return self
:followGesLink(ges
)
700 function Client
:onDoubleTap(_
, ges
)
702 return self
:followGesLink(ges
, true)
706 function Client
:onHoldPan(_
, ges
)
710 function Client
:onHoldRelease(_
, ges
)
711 if self
.active
and not self
.hold_pan
then
712 if self
:followGesLink(ges
, true) then
713 if self
.ui
.highlight
then
714 self
.ui
.highlight
:clear()
719 self
.hold_pan
= false
722 function Client
:onSwipe(_
, ges
)
724 local direction
= BD
.flipDirectionIfMirroredUILayout(ges
.direction
)
725 if direction
== "south" then
727 elseif direction
== "north" then
734 function Client
:followGesLink(ges
, nav
)
735 local link
= self
.ui
.link
:getLinkFromGes(ges
)
736 if link
and link
.xpointer
then
737 local scheme
= link
.xpointer
:match("^(%w[%w+%-.]*):") or ""
738 if util
.arrayContains(SchemeProxies
:supportedSchemes(), scheme
)
739 or (scheme
== "" and self
.active
) then
741 self
:showNav(link
.xpointer
)
743 self
:openUrl(link
.xpointer
)
750 function Client
:onFollowGeminiLink(u
)
751 return self
:showNav(u
)
754 function Client
:onEndOfBook()
755 -- TODO: seems we can't override the usual reader onEndOfBook handling.
756 -- Leaving this as a hidden option for now.
757 if G_reader_settings
:isTrue("gemini_next_on_end") then
758 if self
.active
and #self
.queue
> 0 then
765 function Client
:queueLinksInSelected(selected
)
766 local html
= self
.ui
.document
:getHTMLFromXPointers(selected
.pos0
, selected
.pos1
, nil, true)
768 -- Following pattern isn't strictly correct in general,
769 -- but is for the html generated from a gemini document.
771 for u
in html
:gmatch('<a[^>]*href="([^"]*)"') do
775 UIManager
:show(InfoMessage
:new
{ text
=
776 n
== 0 and _("No links found in selected text.") or
777 T(_("Added %1 links to queue."), n
)
782 function Client
:queueBody(body
, u
, mimetype
, cert_info
, existing_item
, prepend
)
783 util
.makePath(queue_dir
)
784 local path
= queue_dir
.."/"..sha256(u
)
785 if writeBodyToFile(body
, path
) then
786 if existing_item
then
787 existing_item
.path
= path
788 existing_item
.mimetype
= mimetype
789 existing_item
.cert_info
= cert_info
791 self
:queueItem({ url
= u
, path
= path
, mimetype
= mimetype
, cert_info
= cert_info
}, prepend
)
793 elseif not existing_item
then
794 self
:queueItem({ url
= u
}, prepend
)
798 function Client
:queueCachedHistoryItem(h
, prepend
)
799 local body
= io
.open(h
.path
, "r")
801 self
:queueBody(body
, gemini
.showUrl(h
.purl
), h
.mimetype
, h
.cert_info
, nil, prepend
)
805 function Client
:fetchLink(u
, item
, prepend
)
806 self
:openUrl(u
, { body_cb
= function(body
, mimetype
, purl
, cert_info
)
807 self
:queueBody(body
, gemini
.showUrl(purl
), mimetype
, cert_info
, item
, prepend
)
811 function Client
:fetchQueue()
812 for _n
,item
in ipairs(self
.queue
) do
813 if not item
.path
then
814 self
:fetchLink(item
.url
, item
)
819 function Client
:queueLink(u
, prepend
)
820 local purl
= url
.parse(u
)
821 if purl
and purl
.scheme
~= "about" and
822 not G_reader_settings
:isTrue("gemini_no_fetch_on_add") and NetworkMgr
:isConnected() then
823 self
:fetchLink(u
, nil, prepend
)
825 self
:queueItem({ url
= u
}, prepend
)
829 function Client
:queueItem(item
, prepend
)
830 for k
= #self
.queue
,1,-1 do
831 if self
.queue
[k
].url
== item
.url
then
832 table.remove(self
.queue
,k
)
836 table.insert(self
.queue
, 1, item
)
838 table.insert(self
.queue
, item
)
840 queue_persist
:save(self
.queue
)
843 function Client
:openQueueItem(n
)
845 local item
= self
.queue
[n
]
848 local f
= io
.open(item
.path
, "r")
850 UIManager
:show(InfoMessage
:new
{text
= T(_("Failed to open %1 for reading."), item
.path
)})
852 self
:openBody(f
, item
.mimetype
, url
.parse(item
.url
), item
.cert_info
)
853 FileManager
:deleteFile(item
.path
, true)
856 elseif item
.url
:match("^about:") or NetworkMgr
:isConnected() then
857 self
:openUrl(item
.url
)
860 UIManager
:show(InfoMessage
:new
{text
= T(_("Need network connection to fetch %1"), item
.url
)})
865 function Client
:popQueue(n
)
867 local item
= table.remove(self
.queue
, n
)
868 queue_persist
:save(self
.queue
)
872 function Client
:clearQueue()
873 while #self
.queue
> 0 do
874 local item
= table.remove(self
.queue
, 1)
876 FileManager
:deleteFile(item
.path
, true)
879 queue_persist
:save(self
.queue
)
882 function Client
:getSavePath(purl
, mimetype
, cb
)
884 local add_ext
= false
886 basename
= purl
.path
:gsub("/+$","",1):gsub(".*/","",1)
887 if basename
== "" and purl
.host
then
891 if add_ext
or not basename
:match(".+%..+") then
892 local ext
= self
:mimeToExt(mimetype
)
894 basename
= basename
.."."..ext
901 local function do_save()
902 local fields
= widget
:getFields()
903 local dir
= fields
[2]
906 local path
= dir
.."/"..bn
907 local tp
= lfs
.attributes(path
, "mode")
908 if tp
== "directory" then
909 UIManager
:show(InfoMessage
:new
{text
= _("Path is a directory")})
910 elseif tp
~= nil then
911 UIManager
:show(ConfirmBox
:new
{
912 text
= _("File exists. Overwrite?"),
913 ok_text
= _("Overwrite"),
914 cancel_text
= _("Cancel"),
915 ok_callback
= function()
916 UIManager
:close(widget
)
921 UIManager
:close(widget
)
928 widget
= MultiInputDialog
:new
{
929 title
= _("Save as"),
932 description
= _("Filename"),
936 description
= _("Directory to save under"),
945 callback
= function()
946 UIManager
:close(widget
)
951 is_enter_default
= true,
957 widget
.input_field
[1].enter_callback
= do_save
958 widget
.input_field
[2].enter_callback
= do_save
959 UIManager
:show(widget
)
960 widget
:onShowKeyboard()
963 function Client
:saveCurrent()
964 self
:getSavePath(self
.history
[1].purl
, self
.history
[1].mimetype
, function(path
)
965 ffiutil
.copyFile(self
.history
[1].path
, path
)
966 self
.ui
:saveSettings()
967 if DocSettings
.updateLocation
then
968 DocSettings
.updateLocation(self
.history
[1].path
, path
, true)
973 function Client
:addMark(u
, desc
)
975 self
:writeDefaultBookmarks()
976 local line
= "=> " .. u
977 if desc
and desc
~= "" then
978 line
= line
.. " " .. desc
981 local f
= io
.open(marks_path
, "a")
990 function Client
:addMarkInteractive(uri
)
992 local function add_mark()
993 local fields
= widget
:getFields()
994 if self
:addMark(fields
[2], fields
[1]) then
995 UIManager
:close(widget
)
998 widget
= MultiInputDialog
:new
{
999 title
= _("Add bookmark"),
1002 description
= _("Description (optional)"),
1005 description
= _("URL"),
1006 text
= gemini
.showUrl(uri
),
1014 callback
= function()
1015 UIManager
:close(widget
)
1020 is_enter_default
= true,
1021 callback
= add_mark
,
1026 widget
.input_field
[1].enter_callback
= add_mark
1027 widget
.input_field
[2].enter_callback
= add_mark
1028 UIManager
:show(widget
)
1029 widget
:onShowKeyboard()
1032 function Client
:showHistoryMenu(cb
)
1033 cb
= cb
or function(n
) self
:goBack(n
) end
1035 local history_items
= {}
1036 local function show_history_item(h
)
1037 return gemini
.showUrl(h
.purl
) ..
1038 (h
.path
and " " .. _("(fetched)") or "")
1040 for n
,h
in ipairs(self
.history
) do
1041 table.insert(history_items
, {
1042 text
= T("%1 %2", n
-1, show_history_item(h
)),
1043 callback
= function()
1045 UIManager
:close(menu
)
1047 hold_callback
= function()
1048 UIManager
:close(menu
)
1049 self
:showNav(h
.purl
)
1053 for n
,h
in ipairs(self
.unhistory
) do
1054 table.insert(history_items
, 1, {
1055 text
= T("%1 %2", -n
, show_history_item(h
)),
1056 callback
= function()
1058 UIManager
:close(menu
)
1062 if #history_items
> 1 then
1063 table.insert(history_items
, {
1064 text
= _("Clear all history"),
1065 callback
= function()
1066 UIManager
:show(ConfirmBox
:new
{
1067 text
= T(_("Clear %1 history items?"), #history_items
-1),
1068 ok_text
= _("Clear history"),
1069 cancel_text
= _("Cancel"),
1070 ok_callback
= function()
1072 UIManager
:close(menu
)
1079 title
= _("History"),
1080 item_table
= history_items
,
1081 onMenuHold
= function(_
, item
)
1082 if item
.hold_callback
then
1083 item
.hold_callback()
1086 width
= Screen
:getWidth(), -- backwards compatibility;
1087 height
= Screen
:getHeight(), -- can delete for KOReader >= 2023.04
1089 UIManager
:show(menu
)
1092 function Client
:viewCurrentAsText()
1093 local h
= self
.history
[1]
1094 local f
= io
.open(h
.path
,"r")
1095 UIManager
:show(TextViewer
:new
{
1096 title
= gemini
.showUrl(h
.purl
),
1097 text
= f
and f
:read("a") or "[Error reading file]"
1102 function Client
:showCurrentInfo()
1103 local h
= self
.history
[1]
1105 { _("URL"), gemini
.showUrl(h
.purl
) },
1106 { _("Mimetype"), h
.mimetype
}
1110 table.insert(kv_pairs
, "----")
1111 local cert_info
= self
.history
[1].cert_info
1112 if cert_info
.ca
then
1113 table.insert(kv_pairs
, { _("Trust type"), _("Chain to Certificate Authority") })
1114 for k
, v
in ipairs(cert_info
.ca
) do
1115 table.insert(kv_pairs
, { v
.name
, v
.value
})
1118 if cert_info
.trusted_times
> 0 then
1119 table.insert(kv_pairs
, { _("Trust type"), _("Trust On First Use"), callback
= function()
1120 UIManager
:close(widget
)
1121 self
:openUrl("about:tofu")
1123 table.insert(kv_pairs
, { _("SHA256 digest"), cert_info
.fp
})
1124 table.insert(kv_pairs
, { _("Times seen"), cert_info
.trusted_times
})
1126 table.insert(kv_pairs
, { _("Trust type"), _("Temporarily accepted") })
1127 table.insert(kv_pairs
, { _("SHA256 digest"), cert_info
.fp
})
1129 table.insert(kv_pairs
, { _("Expiry date"), cert_info
.expiry
})
1132 table.insert(kv_pairs
, "----")
1133 table.insert(kv_pairs
, { "Source", _("Select to view page as text"), callback
= function()
1134 self
:viewCurrentAsText()
1136 widget
= KeyValuePage
:new
{
1137 title
= _("Page info"),
1138 kv_pairs
= kv_pairs
,
1140 UIManager
:show(widget
)
1143 function Client
:editQueue()
1146 local function show_queue_item(item
)
1147 return gemini
.showUrl(item
.url
) ..
1148 (item
.path
and " " .. _("(fetched)") or "")
1151 for n
,item
in ipairs(self
.queue
) do
1152 table.insert(items
, {
1153 text
= n
.. " " .. show_queue_item(item
),
1154 callback
= function()
1155 UIManager
:close(menu
)
1156 self
:openQueueItem(n
)
1158 hold_callback
= function()
1159 UIManager
:close(menu
)
1160 self
:showNav(item
.url
)
1163 if not item
.path
then
1164 unfetched
= unfetched
+ 1
1167 if unfetched
> 0 then
1168 table.insert(items
, {
1169 text
= T(_("Fetch %1 unfetched items"), unfetched
),
1170 callback
= function()
1172 UIManager
:close(menu
)
1178 table.insert(items
, {
1179 text
= _("Clear queue"),
1180 callback
= function()
1181 UIManager
:show(ConfirmBox
:new
{
1182 text
= T(_("Clear %1 items from queue?"), #self
.queue
),
1183 ok_text
= _("Clear queue"),
1184 cancel_text
= _("Cancel"),
1185 ok_callback
= function()
1187 UIManager
:close(menu
)
1196 onMenuHold
= function(_
, item
)
1197 if item
.hold_callback
then
1198 item
.hold_callback()
1201 width
= Screen
:getWidth(), -- backwards compatibility;
1202 height
= Screen
:getHeight(), -- can delete for KOReader >= 2023.04
1204 UIManager
:show(menu
)
1207 function Client
:showNav(uri
, showKbd
)
1208 if uri
and type(uri
) ~= "string" then
1209 uri
= url
.build(uri
)
1211 showKbd
= showKbd
or not uri
or uri
== ""
1213 uri
= gemini
.showUrl(self
:purl())
1214 elseif self
:purl() and uri
~= "" then
1215 uri
= url
.absolute(self
:purl(), uri
)
1219 local advanced
= false
1220 local function current_nav_url()
1221 local u
= nav
:getInputText()
1222 if u
:match("^[./?]") then
1223 -- explicitly relative url
1225 u
= url
.absolute(self
:purl(), u
)
1228 -- absolutise if necessary
1229 local purl
= url
.parse(u
)
1230 if purl
and purl
.scheme
== nil and purl
.host
== nil then
1231 u
= "gemini://" .. u
1236 local function current_input_nonempty()
1237 local purl
= url
.parse(current_nav_url())
1238 return purl
and (purl
.host
or purl
.path
)
1240 local function close_nav_keyboard()
1241 if nav
.onCloseKeyboard
then
1242 nav
:onCloseKeyboard()
1243 elseif Version
:getNormalizedCurrentVersion() < 202309010000 then
1244 -- backwards compatibility
1245 if nav
._input_widget
.onCloseKeyboard
then
1246 nav
._input_widget
:onCloseKeyboard()
1250 local function show_hist()
1251 close_nav_keyboard()
1252 self
:showHistoryMenu(function(n
)
1253 UIManager
:close(nav
)
1257 local function queue_nav_url(prepend
)
1258 if current_input_nonempty() then
1259 local u
= current_nav_url()
1260 if u
== gemini
.showUrl(self
:purl()) and self
.history
[1].path
then
1261 self
:queueCachedHistoryItem(self
.history
[1], prepend
)
1263 self
:queueLink(u
, prepend
)
1265 UIManager
:close(nav
)
1268 local function update_buttons()
1269 local u
= current_nav_url()
1270 local purl
= url
.parse(u
)
1271 local id
= Identities
:get(u
)
1272 local text
= T(_("Identity: %1"), id
or _("[none]"))
1273 local id_button
= nav
.button_table
:getButtonById("ident")
1274 if not advanced
then
1275 id_button
:setText(text
, id_button
.width
)
1277 id_button
:enableDisable(advanced
or (purl
and purl
.scheme
== "gemini" and purl
.host
~= ""))
1278 UIManager
:setDirty(id_button
, "ui")
1280 local save_button
= nav
.button_table
:getButtonById("save")
1281 save_button
:enableDisable(purl
and purl
.scheme
and purl
.scheme
~= "about")
1282 UIManager
:setDirty(save_button
, "ui")
1284 local info_button
= nav
.button_table
:getButtonById("info")
1285 info_button
:enableDisable(u
== gemini
.showUrl(self
:purl()))
1286 UIManager
:setDirty(info_button
, "ui")
1288 local function toggle_advanced()
1289 advanced
= not advanced
1290 for _
,row
in ipairs(nav
.button_table
.buttons_layout
) do
1291 for _
,button
in ipairs(row
) do
1292 if button
.text_func
and button
.hold_callback
then
1293 button
:setText(button
.text_func(), button
.width
)
1294 button
.callback
, button
.hold_callback
= button
.hold_callback
, button
.callback
1299 UIManager
:setDirty(nav
, "ui")
1302 nav
= InputDialog
:new
{
1303 title
= _("Gemini navigation"),
1304 width
= Screen
:scaleBySize(550), -- in pixels
1305 input_type
= "text",
1306 input
= uri
and gemini
.showUrl(uri
) or "gemini://",
1310 text_func
= function() return advanced
and _("Edit identity URLs") or _("Identity") end,
1312 callback
= function()
1313 close_nav_keyboard()
1314 Identities
:confAt(current_nav_url(), function()
1318 hold_callback
= function()
1319 close_nav_keyboard()
1324 text_func
= function() return advanced
and _("View as text") or _("Page info") end,
1326 callback
= function()
1327 UIManager
:close(nav
)
1328 self
:showCurrentInfo()
1330 hold_callback
= function()
1331 UIManager
:close(nav
)
1332 self
:viewCurrentAsText()
1338 text_func
= function() return advanced
and _("History") or _("Back") end,
1339 enabled
= #self
.history
> 1,
1340 callback
= function()
1341 UIManager
:close(nav
)
1344 hold_callback
= show_hist
,
1347 text_func
= function() return advanced
and _("History") or _("Unback") end,
1348 enabled
= #self
.unhistory
> 0,
1349 callback
= function()
1350 UIManager
:close(nav
)
1353 hold_callback
= show_hist
,
1356 text_func
= function() return advanced
and _("Edit queue") or _("Next") end,
1357 enabled
= #self
.queue
> 0,
1358 callback
= function()
1359 UIManager
:close(nav
)
1360 self
:openQueueItem()
1362 hold_callback
= function()
1363 UIManager
:close(nav
)
1368 text_func
= function() return advanced
and _("Edit marks") or _("Bookmarks") end,
1369 callback
= function()
1370 UIManager
:close(nav
)
1371 self
:openUrl("about:bookmarks")
1373 hold_callback
= function()
1374 if self
.ui
.texteditor
and self
.ui
.texteditor
.quickEditFile
then
1375 UIManager
:close(nav
)
1376 self
:writeDefaultBookmarks()
1377 local function done_cb()
1378 if self
:purl() and url
.build(self
:purl()) == "about:bookmarks" then
1379 self
:openUrl("about:bookmarks", { replace_history
= true })
1382 self
.ui
.texteditor
:quickEditFile(marks_path
, done_cb
, true)
1384 UIManager
:show(InfoMessage
:new
{text
= T(_([[
1385 Can't load TextEditor: Plugin disabled or incompatible.
1386 To edit bookmarks, please edit the file %1 in the koreader directory manually.
1394 text_func
= function() return advanced
and _("Root") or _("Up") end,
1395 callback
= function()
1396 nav
:setInputText(gemini
.upUrl(current_nav_url()))
1399 hold_callback
= function()
1400 nav
:setInputText(url
.absolute(current_nav_url(), "/"))
1407 callback
= function()
1408 local u
= current_nav_url()
1409 local purl
= url
.parse(u
)
1410 if purl
and purl
.scheme
and purl
.scheme
== "about" then
1411 UIManager
:show(InfoMessage
:new
{text
= _("Can't save about: pages")})
1412 elseif u
== gemini
.showUrl(self
:purl()) then
1413 UIManager
:close(nav
)
1416 UIManager
:close(nav
)
1417 self
:openUrl(u
, { body_cb
= function(f
, mimetype
, p2
)
1418 self
:saveBody(f
, mimetype
, p2
)
1424 text_func
= function() return advanced
and _("Prepend") or _("Add") end,
1425 callback
= queue_nav_url
,
1426 hold_callback
= function() queue_nav_url(true) end,
1429 text_func
= function() return advanced
and _("Quick mark") or _("Mark") end,
1430 callback
= function()
1431 if current_input_nonempty() then
1432 self
:addMarkInteractive(current_nav_url())
1433 UIManager
:close(nav
)
1436 hold_callback
= function()
1437 if current_input_nonempty()
1438 and self
:addMark(current_nav_url()) then
1439 UIManager
:close(nav
)
1448 callback
= function()
1449 UIManager
:close(nav
)
1453 text_func
= function() return advanced
and _("Input") or _("Go") end,
1454 is_enter_default
= true,
1455 callback
= function()
1456 UIManager
:close(nav
)
1457 local u
= current_nav_url()
1458 self
:openUrl(u
, { after_err_cb
= function() self
:showNav(u
, true) end})
1460 hold_callback
= function()
1461 local purl
= url
.parse(current_nav_url())
1463 self
:userPromptInput(purl
)
1464 UIManager
:close(nav
)
1472 -- FIXME: less hacky way to do this?
1473 nav
._input_widget
.edit_callback
= function(edited
)
1479 nav
.title_bar
.right_icon
= "appbar.settings"
1480 nav
.title_bar
.right_icon_tap_callback
= toggle_advanced
1481 nav
.title_bar
:init()
1485 nav
:onShowKeyboard()
1489 function Client
:onBrowseGemini()
1492 elseif #self
.history
> 0 then
1494 elseif G_reader_settings
:nilOrFalse("gemini_initiated") then
1495 self
:openUrl("about:welcome")
1497 self
:openUrl("about:bookmarks")
1502 function Client
:onGeminiBack()
1508 function Client
:onGeminiUnback()
1514 function Client
:onGeminiHistory()
1516 self
:showHistoryMenu()
1520 function Client
:onGeminiBookmarks()
1521 self
:openUrl("about:bookmarks")
1524 function Client
:onGeminiMark()
1526 self
:addMarkInteractive(gemini
.showUrl(self
:purl()))
1530 function Client
:onGeminiNext()
1531 self
:openQueueItem()
1534 function Client
:onGeminiAdd()
1536 self
:queueCachedHistoryItem(self
.history
[1])
1540 function Client
:onGeminiInput()
1542 self
:userPromptInput(self
.history
[1].purl
)
1546 function Client
:onGeminiReload()
1548 self
:openUrl(self
.history
[1].purl
, { replace_history
= true })
1552 function Client
:onGeminiUp()
1554 local u
= gemini
.showUrl(self
:purl())
1555 local up
= gemini
.upUrl(u
)
1562 function Client
:onGeminiGoNew()
1566 function Client
:onGeminiNav()
1571 function Client
:addToMainMenu(menu_items
)
1572 menu_items
.gemini
= {
1573 sorting_hint
= "search",
1574 text
= _("Browse Gemini"),
1575 callback
= function()
1576 self
:onBrowseGemini()
1579 local hint
= "search_settings"
1580 if Version
:getNormalizedCurrentVersion() < 202305180000 then
1581 -- backwards compatibility
1584 menu_items
.gemini_settings
= {
1585 text
= _("Gemini settings"),
1586 sorting_hint
= hint
,
1589 text
= _("Show help"),
1590 callback
= function()
1591 self
:openUrl("about:help")
1595 text
= T(_("Max cached history items: %1"), max_cache_history_items
),
1596 help_text
= _("History items up to this limit will be stored on the filesystem and can be accessed offline with Back."),
1597 keep_menu_open
= true,
1598 callback
= function(touchmenu_instance
)
1599 local widget
= SpinWidget
:new
{
1600 title_text
= _("Max cached history items"),
1601 value
= max_cache_history_items
,
1604 default_value
= default_max_cache_history_items
,
1605 callback
= function(spin
)
1606 max_cache_history_items
= spin
.value
1607 G_reader_settings
:saveSetting("gemini_max_cache_history_items", spin
.value
)
1608 touchmenu_instance
:updateItems()
1611 UIManager
:show(widget
)
1615 text
= _("Set directory for saved documents"),
1616 keep_menu_open
= true,
1617 callback
= function()
1618 local title_header
= _("Current directory for saved gemini documents:")
1619 local current_path
= save_dir
1620 local default_path
= getDefaultSavesDir()
1621 local function caller_callback(path
)
1623 G_reader_settings
:saveSetting("gemini_save_dir", path
)
1624 if not util
.pathExists(path
) then
1628 filemanagerutil
.showChooseDialog(title_header
, caller_callback
, current_path
, default_path
)
1632 text
= _("Configure scheme proxies"),
1633 help_text
= _("Configure proxy servers to use for non-gemini URL schemes."),
1634 callback
= function()
1635 SchemeProxies
:edit()
1639 text
= _("Disable fetch on add"),
1640 help_text
= _("Disables immediately fetching URLs added to the queue when connected."),
1641 checked_func
= function()
1642 return G_reader_settings
:isTrue("gemini_no_fetch_on_add")
1644 callback
= function()
1645 G_reader_settings
:flipNilOrFalse("gemini_no_fetch_on_add")
1649 text
= _("Confirm certificates for new hosts"),
1650 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."),
1651 checked_func
= function()
1652 return G_reader_settings
:isTrue("gemini_confirm_tofu")
1654 callback
= function()
1655 G_reader_settings
:flipNilOrFalse("gemini_confirm_tofu")