show kbd on nav with empty uri
[gemini.koplugin.git] / main.lua
blobe98ac44c2c6c27a0e20b08b39940f5887f43a024
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 {}
54 local history = {}
55 local unhistory = {}
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
64 dir = dir .. "/"
65 end
66 return dir .. "downloaded"
67 end
68 local save_dir = G_reader_settings:readSetting("gemini_save_dir") or getDefaultSavesDir()
70 local GeminiPlugin = WidgetContainer:extend{
71 name = "gemini",
72 fullname = _("Gemini plugin"),
73 active = false,
74 hold_pan = false,
77 -- Parsed URL of current item
78 function GeminiPlugin:purl()
79 if #history > 0 then
80 return history[1].purl
81 end
82 end
84 local done_setup = false
85 function GeminiPlugin:setup()
86 util.makePath(ids_dir)
87 self:onDispatcherRegisterActions()
88 if not done_setup then
89 done_setup = true
90 local GeminiDocument = require("geminidocument")
91 DocumentRegistry:addProvider("gmi", "text/gemini", GeminiDocument, 100)
92 end
93 end
95 local activating = false
96 local repl_purl
97 function GeminiPlugin:init()
98 self:setup()
100 self.active = activating
101 if self.active then
102 assert(self:purl())
103 local postcb = self.ui.registerPostReaderReadyCallback
104 if not postcb then
105 -- Name for the callback in versions <= 2024.04
106 postcb = self.ui.registerPostReadyCallback
108 if postcb then
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)
113 end)
115 if repl_purl then
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)
121 end)
122 end)
126 activating = false
127 repl_purl = nil
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")
133 if self.active then
134 self.ui.link:registerScheme("")
137 self.ui.link:addToExternalLinkDialog("23_gemini", function(this, link_url)
138 return {
139 text = _("Open via Gemini"),
140 callback = function()
141 UIManager:close(this.external_link_dialog)
142 this.ui:handleEvent(Event:new("FollowGeminiLink", link_url))
143 end,
144 show_in_dialog_func = function(u)
145 local scheme = u:match("^(%w[%w+%-.]*):") or ""
146 return scheme == "gemini" or scheme == "about" or
147 (scheme == "" and self.active)
148 end,
150 end)
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)
157 return {
158 text = _("Add links to queue"),
159 show_in_highlight_dialog_func = function()
160 return self.active
161 end,
162 callback = function()
163 self:queueLinksInSelected(this.selected_text)
164 this:onClose()
165 end,
167 end)
170 if Device:isTouchDevice() then
171 self.ui:registerTouchZones({
173 id = "tap_link_gemini",
174 ges = "tap",
175 screen_zone = {
176 ratio_x = 0, ratio_y = 0,
177 ratio_w = 1, ratio_h = 1,
179 overrides = {
180 -- Tap on gemini links has priority over everything
181 "tap_link",
182 "readerhighlight_tap",
183 "tap_top_left_corner",
184 "tap_top_right_corner",
185 "tap_left_bottom_corner",
186 "tap_right_bottom_corner",
187 "readerfooter_tap",
188 "readerconfigmenu_ext_tap",
189 "readerconfigmenu_tap",
190 "readermenu_ext_tap",
191 "readermenu_tap",
192 "tap_forward",
193 "tap_backward",
195 handler = function(ges) return self:onTap(nil, ges) end,
198 id = "hold_release_link_gemini",
199 ges = "hold_release",
200 screen_zone = {
201 ratio_x = 0, ratio_y = 0,
202 ratio_w = 1, ratio_h = 1,
204 overrides = {
205 "readerhighlight_hold_release",
207 handler = function(ges) return self:onHoldRelease(nil, ges) end,
210 id = "hold_pan_link_gemini",
211 ges = "hold_pan",
212 screen_zone = {
213 ratio_x = 0, ratio_y = 0,
214 ratio_w = 1, ratio_h = 1,
216 overrides = {
217 "readerhighlight_hold_pan",
219 handler = function(ges) return self:onHoldPan(nil, ges) end,
222 id = "double_tap_link_gemini",
223 ges = "double_tap",
224 screen_zone = {
225 ratio_x = 0, ratio_y = 0,
226 ratio_w = 1, ratio_h = 1,
228 overrides = {
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,
239 id = "swipe_gemini",
240 ges = "swipe",
241 screen_zone = {
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")
259 if o then
260 if type(body) == "string" then
261 o:write(body)
262 o:close()
263 return true
264 else
265 local chunk, aborted = body:read(256)
266 while chunk and chunk ~= "" do
267 o:write(chunk)
268 chunk, aborted = body:read(256)
270 body:close()
271 o:close()
272 return not aborted
274 else
275 return false
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)
285 end)
288 function GeminiPlugin:openBody(body, mimetype, purl, cert_info, replace_history)
289 util.makePath(history_dir)
291 local function get_ext(p)
292 if p.path then
293 local ext, m = p.path:gsub(".*%.","",1)
294 if m == 1 then
295 return ext
299 local hn = #history
300 if replace_history then
301 hn = hn - 1
303 local tn = history_dir .. "/Gemini " .. hn
304 local ext = self:mimeToExt(mimetype) or get_ext(purl)
305 if ext then
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)
316 end,
318 return
321 if not writeBodyToFile(body, tn) then
322 return
325 local history_item = { purl = purl, path = tn, mimetype = mimetype, cert_info = cert_info }
326 if replace_history then
327 history[1] = history_item
328 else
329 -- Delete history tail
330 local ok, iter, dir_obj = pcall(lfs.dir, history_dir)
331 if not ok then
332 return
335 for f in iter, dir_obj do
336 local path = history_dir.."/"..f
337 local attr = lfs.attributes(path) or {}
338 if attr.mode == "file" or attr.mode == "link" then
339 local n = tonumber(f:match("^Gemini (%d+)%f[^%d]"))
340 if n and (n >= #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
344 h.path = nil
349 while table.remove(unhistory) do end
351 table.insert(history, 1, history_item)
354 self:openCurrent()
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
383 activating = true
385 if self.ui.name == "ReaderUI" then
386 --self.ui:switchDocument(history[1].path)
387 switchDocumentSeamlessly(history[1].path)
388 else
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
398 return nil
400 purl.query = nil
401 return url.build(purl)
404 function GeminiPlugin:setIdentity(at_url, identity)
405 local n = normaliseIdentUrl(at_url)
406 if n then
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)
415 if n == nil then
416 return nil
419 for u,id in pairs(active_identities) do
420 if u == n then
421 return id,u
425 local up = url.absolute(at_url, "./")
426 if up == at_url then
427 up = url.absolute(at_url, "../")
429 if up ~= at_url then
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")
437 if f then
438 f:write(require("staticgemtexts").default_bookmarks)
439 f:close()
444 function GeminiPlugin:openUrl(article_url, opts)
445 if type(article_url) ~= "string" then
446 article_url = url.build(article_url)
448 opts = opts or {}
449 local bodyCallback = opts.bodyCallback or function(f, mimetype, p, cert_info)
450 self:openBody(f, mimetype, p, cert_info, opts.replace_history)
452 if self:purl() then
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
459 local body
460 if purl.path == "bookmarks" then
461 self:writeDefaultBookmarks()
462 body = io.open(marks_path, "r")
463 else
464 body = require("staticgemtexts")[purl.path]
466 if body then
467 bodyCallback(body, "text/gemini", purl)
468 else
469 UIManager:show(InfoMessage:new{text = _("Unknown \"about:\" URL: " .. article_url)})
471 return
474 if not purl or not purl.host or purl.scheme ~= "gemini" then
475 UIManager:show(InfoMessage:new{text = _("Invalid gemini URL: " .. article_url)})
476 return
479 -- FIXME: This seems not to work for some reason.
480 --[[
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!
483 return
487 if not NetworkMgr:isOnline() then
488 UIManager:show(InfoMessage:new{text = "Can't connect to server: Not online."})
489 return
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)
501 if major then
502 msg = T(_("Server reports %1: %2"), msg, server_msg)
504 if major == "1" then
505 self:promptInput(p, server_msg, minor == "1")
506 elseif major == "6" then
507 UIManager:show(ConfirmBox:new{
508 text = msg,
509 ok_text = _("Set identity"),
510 cancel_text = _("Cancel"),
511 ok_callback = function()
512 self:confIdentAt(gemini.showUrl(p), function(new_id)
513 if new_id then
514 self:openUrl(p, opts)
516 end)
517 end,
519 else
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
525 cb("once")
526 else
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
544 trust_store,
545 check_trust_cb,
546 trust_modified_cb,
547 success_cb,
548 error_cb,
549 perm_redir_cb,
550 info_cb,
551 G_reader_settings:isTrue("gemini_confirm_tofu"))
552 Trapper:reset()
553 end)
556 -- Prompt user for input. May modify `purl.query`.
557 function GeminiPlugin:promptInput(purl, prompt, is_secret, repl)
558 local input_dialog
559 local repl_button
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) })
566 else
567 UIManager:close(input_dialog)
568 self:openUrl(purl, { repl_purl = repl_button.checked and purl })
571 input_dialog = InputDialog:new{
572 title = prompt,
573 input = "",
574 text_type = is_secret and "password",
575 enter_callback = submit,
576 buttons = {
579 text = _("Cancel"),
580 id = "close",
581 callback = function()
582 UIManager:close(input_dialog)
583 end,
586 text = _("Enter"),
587 callback = submit,
593 -- read-eval-print-loop mode: keep presenting input dialog
594 repl_button = CheckButton:new{
595 text = _("Repeat"),
596 checked = repl,
597 parent = input_dialog,
599 multiline_button = CheckButton:new{
600 text = _("Multiline input"),
601 checked = false,
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
607 else
608 input_dialog._input_widget.enter_callback = submit
610 end,
611 parent = input_dialog,
613 input_dialog:addWidget(multiline_button)
614 input_dialog:addWidget(repl_button)
616 local y_offset = 0
617 if repl then
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()
628 return true
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
634 T(_([[
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
638 T(_([[
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()
644 cb("always")
645 end,
646 choice2_text = _("Connect without trust"),
647 choice2_callback = function()
648 -- persist for 1h
649 trust_overrides[new_fp] = os.time() + 3600
650 cb("once")
651 end,
652 cancel_callback = function()
653 cb()
654 end,
656 UIManager:show(widget)
659 function GeminiPlugin:goBack(n)
660 n = n or 1
661 if n > #history-1 then
662 n = #history-1
663 elseif n < -#unhistory then
664 n = -#unhistory
666 if n == 0 then
667 return false
669 while n > 0 do
670 table.insert(unhistory, 1, table.remove(history, 1))
671 n = n-1
673 while n < 0 do
674 table.insert(history, 1, table.remove(unhistory, 1))
675 n = n+1
677 self:openCurrent()
678 return true
681 function GeminiPlugin:clearHistory()
682 local function delete_item(item)
683 if item.path then
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)
697 if self.active then
698 return self:followGesLink(ges)
702 function GeminiPlugin:onDoubleTap(_, ges)
703 if self.active then
704 return self:followGesLink(ges, true)
708 function GeminiPlugin:onHoldPan(_, ges)
709 self.hold_pan = true
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)
720 if self.active then
721 local direction = BD.flipDirectionIfMirroredUILayout(ges.direction)
722 if direction == "south" then
723 return self:goBack()
724 elseif direction == "north" then
725 self:showNav()
726 return true
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
737 if nav then
738 self:showNav(link.xpointer)
739 else
740 self:openUrl(link.xpointer)
742 return true
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)
753 if html then
754 -- Following pattern isn't strictly correct in general,
755 -- but is for the html generated from a gemini document.
756 local n = 0
757 for u in html:gmatch('<a[^>]*href="([^"]*)"') do
758 self:queueLink(u)
759 n = n + 1
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
776 else
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")
786 if body then
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)
794 end})
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)
810 else
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)
821 if prepend then
822 table.insert(queue, 1, item)
823 else
824 table.insert(queue, item)
826 queue_persist:save(queue)
829 function GeminiPlugin:openQueueItem(n)
830 n = n or 1
831 local item = queue[n]
832 if item then
833 if item.path then
834 local f = io.open(item.path, "r")
835 if not f then
836 UIManager:show(InfoMessage:new{text = T(_("Failed to open %1 for reading."), item.path)})
837 else
838 self:openBody(f, item.mimetype, url.parse(item.url), item.cert_info)
839 FileManager:deleteFile(item.path, true)
840 self:popQueue(n)
842 elseif item.url:match("^about:") or NetworkMgr:isOnline() then
843 self:openUrl(item.url)
844 self:popQueue(n)
845 else
846 UIManager:show(InfoMessage:new{text = T(_("Need to be online to fetch %1"), item.url)})
851 function GeminiPlugin:popQueue(n)
852 n = n or 1
853 local item = table.remove(queue, n)
854 queue_persist:save(queue)
855 return item
858 function GeminiPlugin:clearQueue()
859 while #queue > 0 do
860 local item = table.remove(queue, 1)
861 if item.path then
862 FileManager:deleteFile(item.path, true)
865 queue_persist:save(queue)
868 function GeminiPlugin:getSavePath(purl, mimetype, cb)
869 local basename = ""
870 local add_ext = false
871 if purl.path then
872 basename = purl.path:gsub("/+$","",1):gsub(".*/","",1)
873 if basename == "" and purl.host then
874 basename = purl.host
875 add_ext = true
877 if add_ext or not basename:match(".+%..+") then
878 local ext = self:mimeToExt(mimetype)
879 if ext then
880 basename = basename.."."..ext
885 local widget
887 local function do_save()
888 local fields = widget:getFields()
889 local dir = fields[2]
890 local bn = fields[1]
891 if bn ~= "" then
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)
903 cb(path)
904 end,
906 else
907 UIManager:close(widget)
908 util.makePath(dir)
909 cb(path)
914 widget = MultiInputDialog:new{
915 title = _("Save as"),
916 fields = {
918 description = _("Filename"),
919 text = basename,
922 description = _("Directory to save under"),
923 text = save_dir,
926 buttons = {
929 text = _("Cancel"),
930 id = "close",
931 callback = function()
932 UIManager:close(widget)
933 end,
936 text = _("Save"),
937 is_enter_default = true,
938 callback = do_save,
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)
954 end)
957 function GeminiPlugin:confIdentAt(uri, cb)
958 local n = normaliseIdentUrl(uri)
959 if n == nil then
960 return
963 local widget
964 local id = self:getIdentity(n)
965 local function set_id(new_id)
966 if new_id then
967 self:setIdentity(n, new_id)
968 id = new_id
969 UIManager:close(widget)
970 if cb then
971 cb(id)
972 else
973 self:confIdentAt(n)
977 widget = ButtonDialogTitle:new{
978 title = T(_("Identity at %1"), gemini.showUrl(n)),
979 buttons = {
982 text = id and T(_("Stop using identity %1"), id) or _("No identity in use"),
983 enabled = id ~= nil,
984 callback = function()
985 local delId
986 delId = function()
987 local c_id, at_url = self:getIdentity(n)
988 if c_id then
989 self:setIdentity(at_url, nil)
990 delId()
993 delId()
994 UIManager:close(widget)
995 if cb then
996 cb(nil)
998 end,
1003 text = _("Cancel"),
1004 id = "close",
1005 callback = function()
1006 UIManager:close(widget)
1010 text = _("Choose identity"),
1011 callback = function()
1012 self:chooseIdentity(set_id)
1013 end,
1016 text = _("Create identity"),
1017 enabled = os.execute("openssl version >& /dev/null"),
1018 callback = function()
1019 self:createIdentInteractive(set_id)
1020 end,
1025 UIManager:show(widget)
1028 function GeminiPlugin:getIds()
1029 local ids = {}
1030 util.findFiles(ids_dir, function(path,crt)
1031 if crt:find("%.crt$") then
1032 table.insert(ids, crt:sub(0,-5))
1034 end)
1035 table.sort(ids)
1036 return ids
1039 function GeminiPlugin:chooseIdentity(callback)
1040 local ids = self:getIds()
1042 local widget
1043 local items = {}
1044 for _i,id in ipairs(ids) do
1045 table.insert(items,
1047 text = id,
1048 callback = function()
1049 callback(id)
1050 UIManager:close(widget)
1051 end,
1054 widget = Menu:new{
1055 title = _("Choose identity"),
1056 item_table = items,
1058 UIManager:show(widget)
1061 function GeminiPlugin:createIdentInteractive(callback)
1062 local widget
1063 local rsa_button
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
1068 if not rsa then
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.."'")
1071 else
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)
1075 callback(id)
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)
1092 end,
1094 else
1095 createId(fields[1], fields[2])
1098 widget = MultiInputDialog:new{
1099 title = _("Create identity"),
1100 fields = {
1102 description = _("Identity petname"),
1105 description = _("Name (optional, sent to server)"),
1108 buttons = {
1111 text = _("Cancel"),
1112 id = "close",
1113 callback = function()
1114 UIManager:close(widget)
1115 end,
1118 text = _("Create"),
1119 callback = function()
1120 create_cb()
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"),
1132 checked = false,
1133 parent = widget,
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
1148 line = line .. "\n"
1149 local f = io.open(marks_path, "a")
1150 if f then
1151 f:write(line)
1152 f:close()
1153 return true
1158 function GeminiPlugin:addMarkInteractive(uri)
1159 local widget
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"),
1168 fields = {
1170 description = _("Description (optional)"),
1173 description = _("URL"),
1174 text = gemini.showUrl(uri),
1177 buttons = {
1180 text = _("Cancel"),
1181 id = "close",
1182 callback = function()
1183 UIManager:close(widget)
1184 end,
1187 text = _("Add"),
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)
1201 local menu
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()
1211 cb(n-1)
1212 UIManager:close(menu)
1213 end,
1214 hold_callback = function()
1215 UIManager:close(menu)
1216 self:showNav(h.purl)
1217 end,
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()
1224 cb(-n)
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()
1238 self:clearHistory()
1239 UIManager:close(menu)
1240 end,
1245 menu = Menu:new{
1246 title = _("History"),
1247 item_table = history_items,
1248 onMenuHold = function(_, item)
1249 if item.hold_callback then
1250 item.hold_callback()
1252 end,
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]"
1264 f:close()
1267 function GeminiPlugin:showCurrentInfo()
1268 local h = history[1]
1269 local kv_pairs = {
1270 { _("URL"), gemini.showUrl(h.purl) },
1271 { _("Mimetype"), h.mimetype }
1273 local widget
1274 if h.cert_info then
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 })
1282 else
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")
1287 end })
1288 table.insert(kv_pairs, { _("SHA256 digest"), cert_info.fp })
1289 table.insert(kv_pairs, { _("Times seen"), cert_info.trusted_times })
1290 else
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()
1300 end })
1301 widget = KeyValuePage:new{
1302 title = _("Page info"),
1303 kv_pairs = kv_pairs,
1305 UIManager:show(widget)
1308 function GeminiPlugin:editQueue()
1309 local menu
1310 local items = {}
1311 local function show_queue_item(item)
1312 return gemini.showUrl(item.url) ..
1313 (item.path and " " .. _("(fetched)") or "")
1315 local unfetched = 0
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)
1322 end,
1323 hold_callback = function()
1324 UIManager:close(menu)
1325 self:showNav(item.url)
1326 end,
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()
1336 self:fetchQueue()
1337 UIManager:close(menu)
1338 self:editQueue()
1342 if #items > 0 then
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()
1351 self:clearQueue()
1352 UIManager:close(menu)
1353 end,
1358 menu = Menu:new{
1359 title = _("Queue"),
1360 item_table = items,
1361 onMenuHold = function(_, item)
1362 if item.hold_callback then
1363 item.hold_callback()
1365 end,
1367 UIManager:show(menu)
1370 function GeminiPlugin:editActiveIdentities()
1371 local menu
1372 local items = {}
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()
1386 end,
1388 end,
1391 table.sort(items, function(i1,i2) return i1.text < i2.text end)
1392 menu = Menu:new{
1393 title = _("Active identities"),
1394 item_table = items,
1396 UIManager:show(menu)
1399 function GeminiPlugin:showNav(uri)
1400 if not uri then
1401 uri = gemini.showUrl(self:purl())
1402 elseif self:purl() and uri ~= "" then
1403 uri = url.absolute(self:purl(), uri)
1406 local nav
1407 local function current_nav_url()
1408 local u = nav:getInputText()
1409 if u:match("^[./?]") then
1410 -- explicitly relative url
1411 if self:purl() then
1412 u = url.absolute(self:purl(), u)
1414 else
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
1421 return 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)
1431 self:goBack(n)
1432 end)
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)
1439 else
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://",
1469 buttons = {
1472 text = _("Identity"),
1473 id = "ident",
1474 callback = function()
1475 nav:onCloseKeyboard()
1476 self:confIdentAt(current_nav_url(), function()
1477 update_buttons()
1478 end)
1479 end,
1480 hold_callback = function()
1481 nav:onCloseKeyboard()
1482 self:editActiveIdentities()
1483 end,
1486 text = _("Page info"),
1487 id = "info",
1488 callback = function()
1489 UIManager:close(nav)
1490 self:showCurrentInfo()
1491 end,
1492 hold_callback = function()
1493 UIManager:close(nav)
1494 self:viewCurrentAsText()
1495 end,
1500 text = _("Back"),
1501 enabled = #history > 1,
1502 callback = function()
1503 UIManager:close(nav)
1504 self:goBack()
1505 end,
1506 hold_callback = show_hist,
1509 text = _("Unback"),
1510 enabled = #unhistory > 0,
1511 callback = function()
1512 UIManager:close(nav)
1513 self:goBack(-1)
1514 end,
1515 hold_callback = show_hist,
1518 text = _("Next"),
1519 enabled = #queue > 0,
1520 callback = function()
1521 UIManager:close(nav)
1522 self:openQueueItem()
1523 end,
1524 hold_callback = function()
1525 UIManager:close(nav)
1526 self:editQueue()
1527 end,
1530 text = _("Bookmarks"),
1531 callback = function()
1532 UIManager:close(nav)
1533 self:openUrl("about:bookmarks")
1534 end,
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)
1545 else
1546 UIManager:show(InfoMessage:new{text =
1547 _("Can't edit bookmarks file: TextEditor plugin not loaded.")})
1549 end,
1554 text = _("Up"),
1555 callback = function()
1556 local u = current_nav_url()
1557 local up = url.absolute(u, "./")
1558 if up == u then
1559 up = url.absolute(u, "../")
1561 nav:setInputText(up)
1562 update_buttons()
1563 end,
1564 hold_callback = function()
1565 nav:setInputText(url.absolute(current_nav_url(), "/"))
1566 update_buttons()
1567 end,
1570 text = _("Save"),
1571 id = "save",
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)
1579 self:saveCurrent()
1580 else
1581 UIManager:close(nav)
1582 self:openUrl(u, { bodyCallback = function(f, mimetype, p2)
1583 self:saveBody(f, mimetype, p2)
1584 end })
1586 end,
1589 text = _("Add"),
1590 callback = queue_nav_url,
1591 hold_callback = function() queue_nav_url(true) end,
1594 text = _("Mark"),
1595 callback = function()
1596 if current_input_nonempty() then
1597 self:addMarkInteractive(current_nav_url())
1598 UIManager:close(nav)
1600 end,
1601 hold_callback = function()
1602 if current_input_nonempty()
1603 and self:addMark(current_nav_url()) then
1604 UIManager:close(nav)
1606 end,
1611 text = _("Cancel"),
1612 id = "close",
1613 callback = function()
1614 UIManager:close(nav)
1615 end,
1618 text = _("Go"),
1619 is_enter_default = true,
1620 callback = function()
1621 UIManager:close(nav)
1622 self:openUrl(current_nav_url())
1623 end,
1624 hold_callback = function()
1625 local purl = url.parse(current_nav_url())
1626 if purl then
1627 self:promptInput(purl, "[Input]")
1628 UIManager:close(nav)
1630 end,
1635 update_buttons()
1636 -- FIXME: less hacky way to do this?
1637 nav._input_widget.edit_callback = function(edited)
1638 if edited then
1639 update_buttons()
1643 UIManager:show(nav)
1644 if uri == "" then
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()
1664 if self.active then
1665 self:showNav()
1666 elseif #history > 0 then
1667 self:openCurrent()
1668 else
1669 self:openUrl("about:bookmarks")
1671 return true
1674 function GeminiPlugin:onGeminiBack()
1675 if self.active then
1676 self:goBack()
1677 return true
1680 function GeminiPlugin:onGeminiUnback()
1681 if self.active then
1682 self:goBack(-1)
1683 return true
1686 function GeminiPlugin:onGeminiBookmarks()
1687 self:openUrl("about:bookmarks")
1688 return true
1690 function GeminiPlugin:onGeminiMark()
1691 if self.active then
1692 self:addMarkInteractive(gemini.showUrl(self:purl()))
1693 return true
1696 function GeminiPlugin:onGeminiNext()
1697 self:openQueueItem()
1698 return true
1700 function GeminiPlugin:onGeminiAdd()
1701 if self.active then
1702 self:queueCachedHistoryItem(history[1])
1703 return true
1706 function GeminiPlugin:onGeminiReload()
1707 if self.active then
1708 self:openUrl(history[1].purl, { replace_history = true })
1709 return true
1712 function GeminiPlugin:onGeminiUp()
1713 if self.active then
1714 local u = gemini.showUrl(self:purl())
1715 local up = url.absolute(u, "./")
1716 if up == u then
1717 up = url.absolute(u, "../")
1719 if up ~= u then
1720 self:openUrl(up)
1722 return true
1725 function GeminiPlugin:onGeminiGoNew()
1726 self:showNav("")
1727 return true
1729 function GeminiPlugin:onGeminiNav()
1730 self:showNav()
1731 return true
1734 function GeminiPlugin:addToMainMenu(menu_items)
1735 menu_items.gemini = {
1736 sorting_hint = "search",
1737 text = _("Browse Gemini"),
1738 callback = function()
1739 self:onBrowseGemini()
1740 end,
1742 local hint = "search_settings"
1743 if Version:getNormalizedCurrentVersion() < 202305180000 then
1744 -- backwards compatibility
1745 hint = "search"
1747 menu_items.gemini_settings = {
1748 text = _("Gemini settings"),
1749 sorting_hint = hint,
1750 sub_item_table = {
1752 text = _("Show help"),
1753 callback = function()
1754 self:openUrl("about:help")
1755 end,
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,
1765 value_min = 0,
1766 value_max = 200,
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()
1772 end,
1774 UIManager:show(widget)
1775 end,
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)
1785 save_dir = path
1786 G_reader_settings:saveSetting("gemini_save_dir", path)
1787 if not util.pathExists(path) then
1788 lfs.mkdir(path)
1791 filemanagerutil.showChooseDialog(title_header, caller_callback, current_path, default_path)
1792 end,
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")
1799 end,
1800 callback = function()
1801 G_reader_settings:flipNilOrFalse("gemini_no_fetch_on_add")
1802 end,
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")
1809 end,
1810 callback = function()
1811 G_reader_settings:flipNilOrFalse("gemini_confirm_tofu")
1812 end,
1818 return GeminiPlugin