persist proxies, and use table for proxy info
[gemini.koplugin.git] / main.lua
blob0fde8175415a17e57e35a3add8e572513d355fac
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 scheme_proxies_persist = Persist:new{ path = gemini_dir .. "/scheme_proxies.lua" }
51 local active_identities = active_identities_persist:load() or {}
52 local queue = queue_persist:load() or {}
53 local trust_store = trust_store_persist:load() or {}
54 local scheme_proxies = scheme_proxies_persist:load() or { gopher = {}, ["http(s)"] = {} }
56 local history = {}
57 local unhistory = {}
58 local trust_overrides = {}
60 local default_max_cache_history_items = 20
61 local max_cache_history_items = G_reader_settings:readSetting("gemini_max_cache_history_items") or default_max_cache_history_items
63 local function getDefaultSavesDir()
64 local dir = G_reader_settings:readSetting("home_dir") or filemanagerutil.getDefaultDir()
65 if dir:sub(-1) ~= "/" then
66 dir = dir .. "/"
67 end
68 return dir .. "downloaded"
69 end
70 local save_dir = G_reader_settings:readSetting("gemini_save_dir") or getDefaultSavesDir()
72 local GeminiPlugin = WidgetContainer:extend{
73 name = "gemini",
74 fullname = _("Gemini plugin"),
75 active = false,
76 hold_pan = false,
79 -- Parsed URL of current item
80 function GeminiPlugin:purl()
81 if #history > 0 then
82 return history[1].purl
83 end
84 end
86 local done_setup = false
87 function GeminiPlugin:setup()
88 util.makePath(ids_dir)
89 self:onDispatcherRegisterActions()
90 if not done_setup then
91 done_setup = true
92 local GeminiDocument = require("geminidocument")
93 DocumentRegistry:addProvider("gmi", "text/gemini", GeminiDocument, 100)
94 end
95 end
97 local activating = false
98 local repl_purl
99 function GeminiPlugin:init()
100 self:setup()
102 self.active = activating
103 if self.active then
104 assert(self:purl())
105 local postcb = self.ui.registerPostReaderReadyCallback
106 if not postcb then
107 -- Name for the callback in versions <= 2024.04
108 postcb = self.ui.registerPostReadyCallback
110 if postcb then
111 if self.ui.document.file then
112 -- Keep gemini history files out of reader history
113 postcb(self.ui, function ()
114 ReadHistory:removeItemByPath(self.ui.document.file)
115 end)
117 if repl_purl then
118 local local_repl_purl = repl_purl
119 postcb(self.ui, function ()
120 -- XXX: Input widget painted over without this delay. Better way?
121 UIManager:scheduleIn(0.1, function ()
122 self:promptInput(local_repl_purl, "[Repeating]", false, true)
123 end)
124 end)
128 activating = false
129 repl_purl = nil
131 if self.ui and self.ui.link then
132 if self.ui.link.registerScheme then
133 self.ui.link:registerScheme("gemini")
134 self.ui.link:registerScheme("about")
135 for scheme,proxy in pairs(scheme_proxies) do
136 if proxy.host then
137 if scheme == "http(s)" then
138 self.ui.link:registerScheme("http")
139 self.ui.link:registerScheme("https")
140 else
141 self.ui.link:registerScheme(scheme)
145 if self.active then
146 self.ui.link:registerScheme("")
149 self.ui.link:addToExternalLinkDialog("23_gemini", function(this, link_url)
150 return {
151 text = _("Open via Gemini"),
152 callback = function()
153 UIManager:close(this.external_link_dialog)
154 this.ui:handleEvent(Event:new("FollowGeminiLink", link_url))
155 end,
156 show_in_dialog_func = function(u)
157 local scheme = u:match("^(%w[%w+%-.]*):") or ""
158 return scheme == "gemini" or scheme == "about" or
159 (scheme == "" and self.active)
160 end,
162 end)
165 self.ui.menu:registerToMainMenu(self)
167 if self.ui and self.ui.highlight then
168 self.ui.highlight:addToHighlightDialog("20_queue_links", function(this)
169 return {
170 text = _("Add links to queue"),
171 show_in_highlight_dialog_func = function()
172 return self.active
173 end,
174 callback = function()
175 self:queueLinksInSelected(this.selected_text)
176 this:onClose()
177 end,
179 end)
182 if self.active and Device:isTouchDevice() then
183 self.ui:registerTouchZones({
185 id = "tap_link_gemini",
186 ges = "tap",
187 screen_zone = {
188 ratio_x = 0, ratio_y = 0,
189 ratio_w = 1, ratio_h = 1,
191 overrides = {
192 -- Tap on gemini links has priority over everything
193 "tap_link",
194 "readerhighlight_tap",
195 "tap_top_left_corner",
196 "tap_top_right_corner",
197 "tap_left_bottom_corner",
198 "tap_right_bottom_corner",
199 "readerfooter_tap",
200 "readerconfigmenu_ext_tap",
201 "readerconfigmenu_tap",
202 "readermenu_ext_tap",
203 "readermenu_tap",
204 "tap_forward",
205 "tap_backward",
207 handler = function(ges) return self:onTap(nil, ges) end,
210 id = "hold_release_link_gemini",
211 ges = "hold_release",
212 screen_zone = {
213 ratio_x = 0, ratio_y = 0,
214 ratio_w = 1, ratio_h = 1,
216 overrides = {
217 "readerhighlight_hold_release",
219 handler = function(ges) return self:onHoldRelease(nil, ges) end,
222 id = "hold_pan_link_gemini",
223 ges = "hold_pan",
224 screen_zone = {
225 ratio_x = 0, ratio_y = 0,
226 ratio_w = 1, ratio_h = 1,
228 overrides = {
229 "readerhighlight_hold_pan",
231 handler = function(ges) return self:onHoldPan(nil, ges) end,
234 id = "double_tap_link_gemini",
235 ges = "double_tap",
236 screen_zone = {
237 ratio_x = 0, ratio_y = 0,
238 ratio_w = 1, ratio_h = 1,
240 overrides = {
241 "double_tap_top_left_corner",
242 "double_tap_top_right_corner",
243 "double_tap_bottom_left_corner",
244 "double_tap_bottom_right_corner",
245 "double_tap_left_side",
246 "double_tap_right_side",
248 handler = function(ges) return self:onDoubleTap(nil, ges) end,
251 id = "swipe_gemini",
252 ges = "swipe",
253 screen_zone = {
254 ratio_x = 0, ratio_y = 0,
255 ratio_w = 1, ratio_h = 1,
257 handler = function(ges) return self:onSwipe(nil, ges) end,
263 function GeminiPlugin:mimeToExt(mimetype)
264 return (mimetype == "text/plain" and "txt")
265 or DocumentRegistry:mimeToExt(mimetype)
266 or (mimetype:find("^text/") and "txt")
269 local function writeBodyToFile(body, path)
270 local o = io.open(path, "w")
271 if o then
272 if type(body) == "string" then
273 o:write(body)
274 o:close()
275 return true
276 else
277 local chunk, aborted = body:read(256)
278 while chunk and chunk ~= "" do
279 o:write(chunk)
280 chunk, aborted = body:read(256)
282 body:close()
283 o:close()
284 return not aborted
286 else
287 return false
291 function GeminiPlugin:saveBody(body, mimetype, purl)
292 self:getSavePath(purl, mimetype, function(path)
293 if not writeBodyToFile(body, path) then
294 -- clear up partial write
295 FileManager:deleteFile(path, true)
297 end)
300 function GeminiPlugin:openBody(body, mimetype, purl, cert_info, replace_history)
301 util.makePath(history_dir)
303 local function get_ext(p)
304 if p.path then
305 local ext, m = p.path:gsub(".*%.","",1)
306 if m == 1 then
307 return ext
311 local hn = #history
312 if replace_history then
313 hn = hn - 1
315 local tn = history_dir .. "/Gemini " .. hn
316 local ext = self:mimeToExt(mimetype) or get_ext(purl)
317 if ext then
318 tn = tn .. "." .. ext
321 if not DocumentRegistry:hasProvider(tn) then
322 UIManager:show(ConfirmBox:new{
323 text = T(_("Can't view file (%1). Save it instead?"), mimetype),
324 ok_text = _("Save file"),
325 cancel_text = _("Cancel"),
326 ok_callback = function()
327 self:saveBody(body, mimetype, purl)
328 end,
330 return
333 if not replace_history then
334 -- Delete history tail
335 local ok, iter, dir_obj = pcall(lfs.dir, history_dir)
336 if not ok then
337 return
340 for f in iter, dir_obj do
341 local path = history_dir.."/"..f
342 local attr = lfs.attributes(path) or {}
343 if attr.mode == "file" or attr.mode == "link" then
344 local n = tonumber(f:match("^Gemini (%d+)%f[^%d]"))
345 if n and (n >= #history or n <= #history - max_cache_history_items) then
346 FileManager:deleteFile(path, true)
347 local h = history[#history - n]
348 if h and h.path == path then
349 h.path = nil
354 while table.remove(unhistory) do end
357 if not writeBodyToFile(body, tn) then
358 return
361 local history_item = { purl = purl, path = tn, mimetype = mimetype, cert_info = cert_info }
362 if replace_history then
363 history[1] = history_item
364 else
365 table.insert(history, 1, history_item)
368 self:openCurrent()
371 function GeminiPlugin:openCurrent()
372 if not history[1].path then
373 return self:openUrl(history[1].purl, { replace_history = true })
376 -- as in ReaderUI:switchDocument, but with seamless option
377 local function switchDocumentSeamlessly(new_file)
378 -- Mimic onShowingReader's refresh optimizations
379 self.ui.tearing_down = true
380 self.ui.dithered = nil
382 self.ui:handleEvent(Event:new("CloseReaderMenu"))
383 self.ui:handleEvent(Event:new("CloseConfigMenu"))
384 self.ui.highlight:onClose() -- close highlight dialog if any
385 self.ui:onClose(false)
387 self.ui:showReader(new_file, nil, true)
390 --self.ui:switchDocument(history[1].path)
391 local open_msg = InfoMessage:new{
392 text = T(_("%1\nOpening..."), gemini.showUrl(history[1].purl, true)),
394 UIManager:show(open_msg)
396 -- Tell new GeminiPlugin instance to consider itself active
397 activating = true
399 if self.ui.name == "ReaderUI" then
400 --self.ui:switchDocument(history[1].path)
401 switchDocumentSeamlessly(history[1].path)
402 else
403 local ReaderUI = require("apps/reader/readerui")
404 ReaderUI:showReader(history[1].path, nil, true)
406 UIManager:close(open_msg)
409 local function normaliseIdentUrl(u)
410 local purl = url.parse(u, {scheme = "gemini", port = "1965"})
411 if purl == nil or purl.scheme ~= "gemini" then
412 return nil
414 purl.query = nil
415 return url.build(purl)
418 function GeminiPlugin:setIdentity(at_url, identity)
419 local n = normaliseIdentUrl(at_url)
420 if n then
421 active_identities[n] = identity
422 active_identities_persist:save(active_identities)
426 -- Return first identity at or above at_url, if any
427 function GeminiPlugin:getIdentity(at_url)
428 local n = normaliseIdentUrl(at_url)
429 if n == nil then
430 return nil
433 for u,id in pairs(active_identities) do
434 if u == n then
435 return id,u
439 local up = url.absolute(at_url, "./")
440 if up == at_url then
441 up = url.absolute(at_url, "../")
443 if up ~= at_url then
444 return self:getIdentity(up)
448 function GeminiPlugin:writeDefaultBookmarks()
449 if not util.fileExists(marks_path) then
450 local f = io.open(marks_path, "w")
451 if f then
452 f:write(require("staticgemtexts").default_bookmarks)
453 f:close()
458 function GeminiPlugin:openUrl(article_url, opts)
459 if type(article_url) ~= "string" then
460 article_url = url.build(article_url)
462 opts = opts or {}
463 local body_cb = opts.body_cb or function(f, mimetype, p, cert_info)
464 self:openBody(f, mimetype, p, cert_info, opts.replace_history)
466 local after_err_cb = opts.after_err_cb or function() end
467 if self:purl() then
468 article_url = url.absolute(self:purl(), article_url)
471 local purl = url.parse(article_url, {port = "1965"})
473 if purl and purl.scheme == "about" then
474 local body
475 if purl.path == "bookmarks" then
476 self:writeDefaultBookmarks()
477 G_reader_settings:makeTrue("gemini_initiated")
478 body = io.open(marks_path, "r")
479 else
480 body = require("staticgemtexts")[purl.path]
482 if body then
483 body_cb(body, "text/gemini", purl)
484 else
485 UIManager:show(InfoMessage:new{text = T(_("Unknown \"about:\" URL: %1"), article_url)})
487 return
490 if not purl or not purl.host then
491 UIManager:show(InfoMessage:new{text = T(_("Invalid URL: %1"), article_url)})
492 return
495 local proxy = self:getSchemeProxy(purl.scheme)
496 if purl.scheme ~= "gemini" and not proxy then
497 UIManager:show(InfoMessage:new{text = T(_("No proxy configured for scheme: %1"), purl.scheme)})
498 return
501 if NetworkMgr:willRerunWhenConnected(function() self:openUrl(article_url, opts) end) then
502 -- Not connected yet, nothing more to do here, NetworkMgr will forward the callback and run it once connected!
503 return
506 local id = self:getIdentity(article_url)
507 local id_path = id and ids_dir.."/"..id
508 local function success_cb(f, p, mimetype, params, cert_info)
509 if opts.repl_purl then
510 repl_purl = opts.repl_purl
512 body_cb(f, mimetype, p, cert_info)
514 local function error_cb(msg, p, major, minor, server_msg)
515 if major then
516 msg = T(_("Server reports %1: %2"), msg, server_msg)
518 if major == "1" then
519 self:promptInput(p, server_msg, minor == "1")
520 elseif major == "6" then
521 UIManager:show(ConfirmBox:new{
522 text = msg,
523 ok_text = _("Set identity"),
524 cancel_text = _("Cancel"),
525 ok_callback = function()
526 self:confIdentAt(gemini.showUrl(p), function(new_id)
527 if new_id then
528 self:openUrl(p, opts)
530 end)
531 end,
533 else
534 UIManager:show(InfoMessage:new{
535 text = msg,
536 dismiss_callback = after_err_cb,
540 local function check_trust_cb(host, new_fp, old_trusted_times, old_expiry, cb)
541 if trust_overrides[new_fp] and os.time() < trust_overrides[new_fp] then
542 cb("once")
543 else
544 self:promptUnexpectedCert(host, new_fp, old_trusted_times, old_expiry, cb)
547 local function trust_modified_cb()
548 trust_store_persist:save(trust_store)
550 local perm_redir_cb = nil -- TODO: automatically edit bookmarks file?
551 local function info_cb(msg, fast)
552 return Trapper:info(msg, fast)
555 Trapper:wrap(function()
556 Trapper:setPausedText(T(_("Abort connection?")))
557 gemini.makeRequest(gemini.showUrl(purl),
558 id and id_path..".key",
559 id and id_path..".crt",
560 nil, -- disable CA-based verification
561 trust_store,
562 check_trust_cb,
563 trust_modified_cb,
564 success_cb,
565 error_cb,
566 perm_redir_cb,
567 info_cb,
568 G_reader_settings:isTrue("gemini_confirm_tofu"),
569 proxy.host)
570 Trapper:reset()
571 end)
574 local function basicInputDialog(title, cb, input, is_secret)
575 local input_dialog
576 input_dialog = InputDialog:new{
577 title = title,
578 input = input or "",
579 text_type = is_secret and "password",
580 enter_callback = cb,
581 buttons = {
584 text = _("Cancel"),
585 id = "close",
586 callback = function()
587 UIManager:close(input_dialog)
588 end,
591 text = _("Enter"),
592 callback = cb,
597 return input_dialog
600 -- Prompt user for input. May modify `purl.query`.
601 function GeminiPlugin:promptInput(purl, prompt, is_secret, repl, initial)
602 local input_dialog
603 local repl_button
604 local multiline_button
605 local function submit()
606 local input = input_dialog:getInputText()
607 purl.query = url.escape(input)
608 if #url.build(purl) > 1024 then
609 UIManager:show(InfoMessage:new{ text =
610 T(_("Input too long (by %1 bytes)"), #url.build(purl) - 1024) })
611 else
612 UIManager:close(input_dialog)
613 self:openUrl(purl, {
614 repl_purl = repl_button.checked and purl,
615 after_err_cb = function() self:promptInput(purl, prompt, is_secret, repl, input) end,
619 input_dialog = basicInputDialog(prompt, submit, initial, is_secret)
621 -- read-eval-print-loop mode: keep presenting input dialog
622 repl_button = CheckButton:new{
623 text = _("Repeat"),
624 checked = repl,
625 parent = input_dialog,
627 multiline_button = CheckButton:new{
628 text = _("Multiline input"),
629 checked = false,
630 callback = function()
631 input_dialog.allow_newline = multiline_button.checked
632 -- FIXME: less hacky way to do this?
633 if multiline_button.checked then
634 input_dialog._input_widget.enter_callback = nil
635 else
636 input_dialog._input_widget.enter_callback = submit
638 end,
639 parent = input_dialog,
641 input_dialog:addWidget(multiline_button)
642 input_dialog:addWidget(repl_button)
644 local y_offset = 0
645 if repl then
646 -- Draw just above keyboard (in vertical mode),
647 -- so we can see as much as possible of the newly loaded page
648 y_offset = Screen:scaleBySize(120)
649 if G_reader_settings:isTrue("keyboard_key_compact") then
650 y_offset = y_offset + 50
654 UIManager:show(input_dialog, nil, nil, nil, y_offset)
655 input_dialog:onShowKeyboard()
656 return true
659 function GeminiPlugin:userPromptInput(purl)
660 self:promptInput(purl, "[Input]", false, false, url.unescape(purl.query or ""))
663 function GeminiPlugin:promptUnexpectedCert(host, new_fp, old_trusted_times, old_expiry, cb)
664 local widget = MultiConfirmBox:new{
665 text = old_trusted_times > 0 and
666 T(_([[
667 The server identity presented by %1 does not match that previously trusted (%2 times).
668 Digest of received certificate: SHA256:%3
669 Previously trusted certificate expiry date: %4]]), host, old_trusted_times, new_fp, old_expiry) or
670 T(_([[
671 No trusted server identity known for %1. Trust provided server identity?
672 Digest of received certificate: SHA256:%2]]), host, new_fp),
673 face = Font:getFace("x_smallinfofont"),
674 choice1_text = _("Trust new certificate"),
675 choice1_callback = function()
676 cb("always")
677 end,
678 choice2_text = _("Connect without trust"),
679 choice2_callback = function()
680 -- persist for 1h
681 trust_overrides[new_fp] = os.time() + 3600
682 cb("once")
683 end,
684 cancel_callback = function()
685 cb()
686 end,
688 UIManager:show(widget)
691 function GeminiPlugin:goBack(n)
692 n = n or 1
693 if n > #history-1 then
694 n = #history-1
695 elseif n < -#unhistory then
696 n = -#unhistory
698 if n == 0 then
699 return false
701 while n > 0 do
702 table.insert(unhistory, 1, table.remove(history, 1))
703 n = n-1
705 while n < 0 do
706 table.insert(history, 1, table.remove(unhistory, 1))
707 n = n+1
709 self:openCurrent()
710 return true
713 function GeminiPlugin:clearHistory()
714 local function delete_item(item)
715 if item.path then
716 FileManager:deleteFile(item.path, true)
720 while #history > 1 do
721 delete_item(table.remove(history, 2))
723 while #unhistory > 0 do
724 delete_item(table.remove(unhistory, 1))
728 function GeminiPlugin:onTap(_, ges)
729 if self.active then
730 return self:followGesLink(ges)
734 function GeminiPlugin:onDoubleTap(_, ges)
735 if self.active then
736 return self:followGesLink(ges, true)
740 function GeminiPlugin:onHoldPan(_, ges)
741 self.hold_pan = true
744 function GeminiPlugin:onHoldRelease(_, ges)
745 if self.active and not self.hold_pan then
746 if self:followGesLink(ges, true) then
747 if self.ui.highlight then
748 self.ui.highlight:clear()
750 return true
753 self.hold_pan = false
756 function GeminiPlugin:onSwipe(_, ges)
757 if self.active then
758 local direction = BD.flipDirectionIfMirroredUILayout(ges.direction)
759 if direction == "south" then
760 return self:goBack()
761 elseif direction == "north" then
762 self:showNav()
763 return true
768 function GeminiPlugin:followGesLink(ges, nav)
769 local link = self.ui.link:getLinkFromGes(ges)
770 if link and link.xpointer then
771 local scheme = link.xpointer:match("^(%w[%w+%-.]*):") or ""
772 if scheme == "gemini" or scheme == "about"
773 or self:getSchemeProxy(scheme)
774 or (scheme == "" and self.active) then
775 if nav then
776 self:showNav(link.xpointer)
777 else
778 self:openUrl(link.xpointer)
780 return true
785 function GeminiPlugin:onFollowGeminiLink(u)
786 return self:showNav(u)
789 function GeminiPlugin:onEndOfBook()
790 -- TODO: seems we can't override the usual reader onEndOfBook handling.
791 -- Leaving this as a hidden option for now.
792 if G_reader_settings:isTrue("gemini_next_on_end") then
793 if self.active and #queue > 0 then
794 self:openQueueItem()
795 return true
800 function GeminiPlugin:queueLinksInSelected(selected)
801 local html = self.ui.document:getHTMLFromXPointers(selected.pos0, selected.pos1, nil, true)
802 if html then
803 -- Following pattern isn't strictly correct in general,
804 -- but is for the html generated from a gemini document.
805 local n = 0
806 for u in html:gmatch('<a[^>]*href="([^"]*)"') do
807 self:queueLink(u)
808 n = n + 1
810 UIManager:show(InfoMessage:new{ text =
811 n == 0 and _("No links found in selected text.") or
812 T(_("Added %1 links to queue."), n)
817 function GeminiPlugin:queueBody(body, u, mimetype, cert_info, existing_item, prepend)
818 util.makePath(queue_dir)
819 local path = queue_dir.."/"..sha256(u)
820 if writeBodyToFile(body, path) then
821 if existing_item then
822 existing_item.path = path
823 existing_item.mimetype = mimetype
824 existing_item.cert_info = cert_info
825 else
826 self:queueItem({ url = u, path = path, mimetype = mimetype, cert_info = cert_info }, prepend)
828 elseif not existing_item then
829 self:queueItem({ url = u }, prepend)
833 function GeminiPlugin:queueCachedHistoryItem(h, prepend)
834 local body = io.open(h.path, "r")
835 if body then
836 self:queueBody(body, gemini.showUrl(h.purl), h.mimetype, h.cert_info, nil, prepend)
840 function GeminiPlugin:fetchLink(u, item, prepend)
841 self:openUrl(u, { body_cb = function(body, mimetype, purl, cert_info)
842 self:queueBody(body, gemini.showUrl(purl), mimetype, cert_info, item, prepend)
843 end})
846 function GeminiPlugin:fetchQueue()
847 for _n,item in ipairs(queue) do
848 if not item.path then
849 self:fetchLink(item.url, item)
854 function GeminiPlugin:queueLink(u, prepend)
855 local purl = url.parse(u)
856 if purl and purl.scheme ~= "about" and
857 not G_reader_settings:isTrue("gemini_no_fetch_on_add") and NetworkMgr:isConnected() then
858 self:fetchLink(u, nil, prepend)
859 else
860 self:queueItem({ url = u }, prepend)
864 function GeminiPlugin:queueItem(item, prepend)
865 for k = #queue,1,-1 do
866 if queue[k].url == item.url then
867 table.remove(queue,k)
870 if prepend then
871 table.insert(queue, 1, item)
872 else
873 table.insert(queue, item)
875 queue_persist:save(queue)
878 function GeminiPlugin:openQueueItem(n)
879 n = n or 1
880 local item = queue[n]
881 if item then
882 if item.path then
883 local f = io.open(item.path, "r")
884 if not f then
885 UIManager:show(InfoMessage:new{text = T(_("Failed to open %1 for reading."), item.path)})
886 else
887 self:openBody(f, item.mimetype, url.parse(item.url), item.cert_info)
888 FileManager:deleteFile(item.path, true)
889 self:popQueue(n)
891 elseif item.url:match("^about:") or NetworkMgr:isConnected() then
892 self:openUrl(item.url)
893 self:popQueue(n)
894 else
895 UIManager:show(InfoMessage:new{text = T(_("Need network connection to fetch %1"), item.url)})
900 function GeminiPlugin:popQueue(n)
901 n = n or 1
902 local item = table.remove(queue, n)
903 queue_persist:save(queue)
904 return item
907 function GeminiPlugin:clearQueue()
908 while #queue > 0 do
909 local item = table.remove(queue, 1)
910 if item.path then
911 FileManager:deleteFile(item.path, true)
914 queue_persist:save(queue)
917 function GeminiPlugin:getSavePath(purl, mimetype, cb)
918 local basename = ""
919 local add_ext = false
920 if purl.path then
921 basename = purl.path:gsub("/+$","",1):gsub(".*/","",1)
922 if basename == "" and purl.host then
923 basename = purl.host
924 add_ext = true
926 if add_ext or not basename:match(".+%..+") then
927 local ext = self:mimeToExt(mimetype)
928 if ext then
929 basename = basename.."."..ext
934 local widget
936 local function do_save()
937 local fields = widget:getFields()
938 local dir = fields[2]
939 local bn = fields[1]
940 if bn ~= "" then
941 local path = dir.."/"..bn
942 local tp = lfs.attributes(path, "mode")
943 if tp == "directory" then
944 UIManager:show(InfoMessage:new{text = _("Path is a directory")})
945 elseif tp ~= nil then
946 UIManager:show(ConfirmBox:new{
947 text = _("File exists. Overwrite?"),
948 ok_text = _("Overwrite"),
949 cancel_text = _("Cancel"),
950 ok_callback = function()
951 UIManager:close(widget)
952 cb(path)
953 end,
955 else
956 UIManager:close(widget)
957 util.makePath(dir)
958 cb(path)
963 widget = MultiInputDialog:new{
964 title = _("Save as"),
965 fields = {
967 description = _("Filename"),
968 text = basename,
971 description = _("Directory to save under"),
972 text = save_dir,
975 buttons = {
978 text = _("Cancel"),
979 id = "close",
980 callback = function()
981 UIManager:close(widget)
982 end,
985 text = _("Save"),
986 is_enter_default = true,
987 callback = do_save,
992 widget.input_field[1].enter_callback = do_save
993 widget.input_field[2].enter_callback = do_save
994 UIManager:show(widget)
995 widget:onShowKeyboard()
998 function GeminiPlugin:saveCurrent()
999 self:getSavePath(history[1].purl, history[1].mimetype, function(path)
1000 ffiutil.copyFile(history[1].path, path)
1001 self.ui:saveSettings()
1002 if DocSettings.updateLocation then
1003 DocSettings.updateLocation(history[1].path, path, true)
1005 end)
1008 function GeminiPlugin:confIdentAt(uri, cb)
1009 local n = normaliseIdentUrl(uri)
1010 if n == nil then
1011 return
1014 local widget
1015 local id = self:getIdentity(n)
1016 local function set_id(new_id)
1017 if new_id then
1018 self:setIdentity(n, new_id)
1019 id = new_id
1020 UIManager:close(widget)
1021 if cb then
1022 cb(id)
1023 else
1024 self:confIdentAt(n)
1028 widget = ButtonDialogTitle:new{
1029 title = T(_("Identity at %1"), gemini.showUrl(n)),
1030 buttons = {
1033 text = id and T(_("Stop using identity %1"), id) or _("No identity in use"),
1034 enabled = id ~= nil,
1035 callback = function()
1036 local delId
1037 delId = function()
1038 local c_id, at_url = self:getIdentity(n)
1039 if c_id then
1040 self:setIdentity(at_url, nil)
1041 delId()
1044 delId()
1045 UIManager:close(widget)
1046 if cb then
1047 cb(nil)
1049 end,
1054 text = _("Cancel"),
1055 id = "close",
1056 callback = function()
1057 UIManager:close(widget)
1061 text = _("Choose identity"),
1062 callback = function()
1063 self:chooseIdentity(set_id)
1064 end,
1067 text = _("Create identity"),
1068 enabled = os.execute("openssl version >& /dev/null"),
1069 callback = function()
1070 self:createIdentInteractive(set_id)
1071 end,
1076 UIManager:show(widget)
1079 function GeminiPlugin:getIds()
1080 local ids = {}
1081 util.findFiles(ids_dir, function(path,crt)
1082 if crt:find("%.crt$") then
1083 table.insert(ids, crt:sub(0,-5))
1085 end)
1086 table.sort(ids)
1087 return ids
1090 function GeminiPlugin:chooseIdentity(callback)
1091 local ids = self:getIds()
1093 local widget
1094 local items = {}
1095 for _i,id in ipairs(ids) do
1096 table.insert(items,
1098 text = id,
1099 callback = function()
1100 callback(id)
1101 UIManager:close(widget)
1102 end,
1105 widget = Menu:new{
1106 title = _("Choose identity"),
1107 item_table = items,
1108 width = Screen:getWidth(), -- backwards compatibility;
1109 height = Screen:getHeight(), -- can delete for KOReader >= 2023.04
1111 UIManager:show(widget)
1114 function GeminiPlugin:createIdentInteractive(callback)
1115 local widget
1116 local rsa_button
1117 local function createId(id, common_name, rsa)
1118 local path = ids_dir.."/"..id
1119 local shell_quoted_name = common_name:gsub("'","'\\''")
1120 local subj = shell_quoted_name == "" and "/" or "/CN="..shell_quoted_name
1121 if not rsa then
1122 os.execute("openssl ecparam -genkey -name prime256v1 > "..path..".key")
1123 os.execute("openssl req -x509 -new -key "..path..".key -sha256 -out "..path..".crt -days 2000000 -subj '"..subj.."'")
1124 else
1125 os.execute("openssl req -x509 -newkey rsa:2048 -keyout "..path..".key -sha256 -out "..path..".crt -days 2000000 -nodes -subj '"..subj.."'")
1127 UIManager:close(widget)
1128 callback(id)
1130 local function create_cb()
1131 local fields = widget:getFields()
1132 if fields[1] == "" then
1133 UIManager:show(InfoMessage:new{text = _("Enter a petname for this identity, to be used in this client to refer to the identity.")})
1134 elseif not fields[1]:match("^[%w_%-]+$") then
1135 UIManager:show(InfoMessage:new{text = _("Punctuation not allowed in petname.")})
1136 elseif fields[1]:len() > 12 then
1137 UIManager:show(InfoMessage:new{text = _("Petname too long.")})
1138 elseif util.fileExists(ids_dir.."/"..fields[1]..".crt") then
1139 UIManager:show(ConfirmBox:new{
1140 text = _("Identity already exists. Overwrite?"),
1141 ok_text = _("Destroy existing identity"),
1142 cancel_text = _("Cancel"),
1143 ok_callback = function()
1144 createId(fields[1], fields[2], rsa_button.checked)
1145 end,
1147 else
1148 createId(fields[1], fields[2])
1151 widget = MultiInputDialog:new{
1152 title = _("Create identity"),
1153 fields = {
1155 description = _("Identity petname"),
1158 description = _("Name (optional, sent to server)"),
1161 buttons = {
1164 text = _("Cancel"),
1165 id = "close",
1166 callback = function()
1167 UIManager:close(widget)
1168 end,
1171 text = _("Create"),
1172 callback = function()
1173 create_cb()
1179 widget.input_field[1].enter_callback = create_cb
1180 widget.input_field[2].enter_callback = create_cb
1182 -- FIXME: Seems checkbuttons added to MultiInputDialog don't appear...
1183 rsa_button = CheckButton:new{
1184 text = _("Use RSA instead of ECDSA"),
1185 checked = false,
1186 parent = widget,
1188 widget:addWidget(rsa_button)
1190 UIManager:show(widget)
1191 widget:onShowKeyboard()
1194 function GeminiPlugin:addMark(u, desc)
1195 if url.parse(u) then
1196 self:writeDefaultBookmarks()
1197 local line = "=> " .. u
1198 if desc and desc ~= "" then
1199 line = line .. " " .. desc
1201 line = line .. "\n"
1202 local f = io.open(marks_path, "a")
1203 if f then
1204 f:write(line)
1205 f:close()
1206 return true
1211 function GeminiPlugin:addMarkInteractive(uri)
1212 local widget
1213 local function add_mark()
1214 local fields = widget:getFields()
1215 if self:addMark(fields[2], fields[1]) then
1216 UIManager:close(widget)
1219 widget = MultiInputDialog:new{
1220 title = _("Add bookmark"),
1221 fields = {
1223 description = _("Description (optional)"),
1226 description = _("URL"),
1227 text = gemini.showUrl(uri),
1230 buttons = {
1233 text = _("Cancel"),
1234 id = "close",
1235 callback = function()
1236 UIManager:close(widget)
1237 end,
1240 text = _("Add"),
1241 is_enter_default = true,
1242 callback = add_mark,
1247 widget.input_field[1].enter_callback = add_mark
1248 widget.input_field[2].enter_callback = add_mark
1249 UIManager:show(widget)
1250 widget:onShowKeyboard()
1253 function GeminiPlugin:showHistoryMenu(cb)
1254 cb = cb or function(n) self:goBack(n) end
1255 local menu
1256 local history_items = {}
1257 local function show_history_item(h)
1258 return gemini.showUrl(h.purl) ..
1259 (h.path and " " .. _("(fetched)") or "")
1261 for n,h in ipairs(history) do
1262 table.insert(history_items, {
1263 text = T("%1 %2", n-1, show_history_item(h)),
1264 callback = function()
1265 cb(n-1)
1266 UIManager:close(menu)
1267 end,
1268 hold_callback = function()
1269 UIManager:close(menu)
1270 self:showNav(h.purl)
1271 end,
1274 for n,h in ipairs(unhistory) do
1275 table.insert(history_items, 1, {
1276 text = T("%1 %2", -n, show_history_item(h)),
1277 callback = function()
1278 cb(-n)
1279 UIManager:close(menu)
1283 if #history_items > 1 then
1284 table.insert(history_items, {
1285 text = _("Clear all history"),
1286 callback = function()
1287 UIManager:show(ConfirmBox:new{
1288 text = T(_("Clear %1 history items?"), #history_items-1),
1289 ok_text = _("Clear history"),
1290 cancel_text = _("Cancel"),
1291 ok_callback = function()
1292 self:clearHistory()
1293 UIManager:close(menu)
1294 end,
1299 menu = Menu:new{
1300 title = _("History"),
1301 item_table = history_items,
1302 onMenuHold = function(_, item)
1303 if item.hold_callback then
1304 item.hold_callback()
1306 end,
1307 width = Screen:getWidth(), -- backwards compatibility;
1308 height = Screen:getHeight(), -- can delete for KOReader >= 2023.04
1310 UIManager:show(menu)
1313 function GeminiPlugin:getSchemeProxy(scheme)
1314 local proxy = scheme_proxies[scheme]
1315 if proxy and proxy.host then
1316 return proxy
1318 if scheme == "http" or scheme == "https" then
1319 return self:getSchemeProxy("http(s)")
1323 function GeminiPlugin:schemeProxyMenu()
1324 local menu
1325 local kv_pairs = {}
1326 for scheme,proxy in pairs(scheme_proxies) do
1327 table.insert(kv_pairs, { scheme, proxy.host or "", callback = function()
1328 local input_dialog
1329 input_dialog = basicInputDialog(
1330 T(_("Set proxy server for %1 URLs"), scheme),
1331 function()
1332 local host = input_dialog:getInputText()
1333 if host == "" then
1334 host = nil
1336 scheme_proxies[scheme].host = host
1337 scheme_proxies_persist:save(scheme_proxies)
1338 UIManager:close(input_dialog)
1339 UIManager:close(menu)
1340 self:schemeProxyMenu()
1341 end,
1342 proxy.host)
1343 UIManager:show(input_dialog)
1344 input_dialog:onShowKeyboard()
1345 end })
1347 table.insert(kv_pairs, { _("[New]"), _("Select to add new scheme"), callback = function()
1348 local input_dialog
1349 input_dialog = basicInputDialog(_("Add new scheme to proxy"), function()
1350 local scheme = input_dialog:getInputText()
1351 UIManager:close(input_dialog)
1352 if scheme then
1353 scheme_proxies[scheme] = {}
1354 UIManager:close(menu)
1355 self:schemeProxyMenu()
1357 end)
1358 UIManager:show(input_dialog)
1359 input_dialog:onShowKeyboard()
1360 end })
1361 menu = KeyValuePage:new{
1362 title = _("Scheme proxies"),
1363 kv_pairs = kv_pairs,
1365 UIManager:show(menu)
1369 function GeminiPlugin:viewCurrentAsText()
1370 local h = history[1]
1371 local f = io.open(h.path,"r")
1372 UIManager:show(TextViewer:new{
1373 title = gemini.showUrl(h.purl),
1374 text = f and f:read("a") or "[Error reading file]"
1376 f:close()
1379 function GeminiPlugin:showCurrentInfo()
1380 local h = history[1]
1381 local kv_pairs = {
1382 { _("URL"), gemini.showUrl(h.purl) },
1383 { _("Mimetype"), h.mimetype }
1385 local widget
1386 if h.cert_info then
1387 table.insert(kv_pairs, "----")
1388 local cert_info = history[1].cert_info
1389 if cert_info.ca then
1390 table.insert(kv_pairs, { _("Trust type"), _("Chain to Certificate Authority") })
1391 for k, v in ipairs(cert_info.ca) do
1392 table.insert(kv_pairs, { v.name, v.value })
1394 else
1395 if cert_info.trusted_times > 0 then
1396 table.insert(kv_pairs, { _("Trust type"), _("Trust On First Use"), callback = function()
1397 UIManager:close(widget)
1398 self:openUrl("about:tofu")
1399 end })
1400 table.insert(kv_pairs, { _("SHA256 digest"), cert_info.fp })
1401 table.insert(kv_pairs, { _("Times seen"), cert_info.trusted_times })
1402 else
1403 table.insert(kv_pairs, { _("Trust type"), _("Temporarily accepted") })
1404 table.insert(kv_pairs, { _("SHA256 digest"), cert_info.fp })
1406 table.insert(kv_pairs, { _("Expiry date"), cert_info.expiry })
1409 table.insert(kv_pairs, "----")
1410 table.insert(kv_pairs, { "Source", _("Select to view page as text"), callback = function()
1411 self:viewCurrentAsText()
1412 end })
1413 widget = KeyValuePage:new{
1414 title = _("Page info"),
1415 kv_pairs = kv_pairs,
1417 UIManager:show(widget)
1420 function GeminiPlugin:editQueue()
1421 local menu
1422 local items = {}
1423 local function show_queue_item(item)
1424 return gemini.showUrl(item.url) ..
1425 (item.path and " " .. _("(fetched)") or "")
1427 local unfetched = 0
1428 for n,item in ipairs(queue) do
1429 table.insert(items, {
1430 text = n .. " " .. show_queue_item(item),
1431 callback = function()
1432 UIManager:close(menu)
1433 self:openQueueItem(n)
1434 end,
1435 hold_callback = function()
1436 UIManager:close(menu)
1437 self:showNav(item.url)
1438 end,
1440 if not item.path then
1441 unfetched = unfetched + 1
1444 if unfetched > 0 then
1445 table.insert(items, {
1446 text = T(_("Fetch %1 unfetched items"), unfetched),
1447 callback = function()
1448 self:fetchQueue()
1449 UIManager:close(menu)
1450 self:editQueue()
1454 if #items > 0 then
1455 table.insert(items, {
1456 text = _("Clear queue"),
1457 callback = function()
1458 UIManager:show(ConfirmBox:new{
1459 text = T(_("Clear %1 items from queue?"), #queue),
1460 ok_text = _("Clear queue"),
1461 cancel_text = _("Cancel"),
1462 ok_callback = function()
1463 self:clearQueue()
1464 UIManager:close(menu)
1465 end,
1470 menu = Menu:new{
1471 title = _("Queue"),
1472 item_table = items,
1473 onMenuHold = function(_, item)
1474 if item.hold_callback then
1475 item.hold_callback()
1477 end,
1478 width = Screen:getWidth(), -- backwards compatibility;
1479 height = Screen:getHeight(), -- can delete for KOReader >= 2023.04
1481 UIManager:show(menu)
1484 function GeminiPlugin:editActiveIdentities()
1485 local menu
1486 local items = {}
1487 for u,id in pairs(active_identities) do
1488 local show_u = gemini.showUrl(u)
1489 table.insert(items, {
1490 text = T("%1: %2", id, show_u),
1491 callback = function()
1492 UIManager:show(ConfirmBox:new{
1493 text = T(_("Stop using identity %1 at %2?"), id, show_u),
1494 ok_text = _("Stop"),
1495 cancel_text = _("Cancel"),
1496 ok_callback = function()
1497 UIManager:close(menu)
1498 self:setIdentity(u, nil)
1499 self:editActiveIdentities()
1500 end,
1502 end,
1505 table.sort(items, function(i1,i2) return i1.text < i2.text end)
1506 menu = Menu:new{
1507 title = _("Active identities"),
1508 item_table = items,
1509 width = Screen:getWidth(), -- backwards compatibility;
1510 height = Screen:getHeight(), -- can delete for KOReader >= 2023.04
1512 UIManager:show(menu)
1515 function GeminiPlugin:showNav(uri, showKbd)
1516 if uri and type(uri) ~= "string" then
1517 uri = url.build(uri)
1519 showKbd = showKbd or not uri or uri == ""
1520 if not uri then
1521 uri = gemini.showUrl(self:purl())
1522 elseif self:purl() and uri ~= "" then
1523 uri = url.absolute(self:purl(), uri)
1526 local nav
1527 local advanced = false
1528 local function current_nav_url()
1529 local u = nav:getInputText()
1530 if u:match("^[./?]") then
1531 -- explicitly relative url
1532 if self:purl() then
1533 u = url.absolute(self:purl(), u)
1535 else
1536 -- absolutise if necessary
1537 local purl = url.parse(u)
1538 if purl and purl.scheme == nil and purl.host == nil then
1539 u = "gemini://" .. u
1542 return u
1544 local function current_input_nonempty()
1545 local purl = url.parse(current_nav_url())
1546 return purl and (purl.host or purl.path)
1548 local function close_nav_keyboard()
1549 if nav.onCloseKeyboard then
1550 nav:onCloseKeyboard()
1551 elseif Version:getNormalizedCurrentVersion() < 202309010000 then
1552 -- backwards compatibility
1553 if nav._input_widget.onCloseKeyboard then
1554 nav._input_widget:onCloseKeyboard()
1558 local function show_hist()
1559 close_nav_keyboard()
1560 self:showHistoryMenu(function(n)
1561 UIManager:close(nav)
1562 self:goBack(n)
1563 end)
1565 local function queue_nav_url(prepend)
1566 if current_input_nonempty() then
1567 local u = current_nav_url()
1568 if u == gemini.showUrl(self:purl()) and history[1].path then
1569 self:queueCachedHistoryItem(history[1], prepend)
1570 else
1571 self:queueLink(u, prepend)
1573 UIManager:close(nav)
1576 local function update_buttons()
1577 local u = current_nav_url()
1578 local purl = url.parse(u)
1579 local id = self:getIdentity(u)
1580 local text = T(_("Identity: %1"), id or _("[none]"))
1581 local id_button = nav.button_table:getButtonById("ident")
1582 if not advanced then
1583 id_button:setText(text, id_button.width)
1585 id_button:enableDisable(advanced or (purl and purl.scheme == "gemini" and purl.host ~= ""))
1586 UIManager:setDirty(id_button, "ui")
1588 local save_button = nav.button_table:getButtonById("save")
1589 save_button:enableDisable(purl and purl.scheme and purl.scheme ~= "about")
1590 UIManager:setDirty(save_button, "ui")
1592 local info_button = nav.button_table:getButtonById("info")
1593 info_button:enableDisable(u == gemini.showUrl(self:purl()))
1594 UIManager:setDirty(info_button, "ui")
1596 local function toggle_advanced()
1597 advanced = not advanced
1598 for _,row in ipairs(nav.button_table.buttons_layout) do
1599 for _,button in ipairs(row) do
1600 if button.text_func and button.hold_callback then
1601 button:setText(button.text_func(), button.width)
1602 button.callback, button.hold_callback = button.hold_callback, button.callback
1606 update_buttons()
1607 UIManager:setDirty(nav, "ui")
1610 nav = InputDialog:new{
1611 title = _("Gemini navigation"),
1612 width = Screen:scaleBySize(550), -- in pixels
1613 input_type = "text",
1614 input = uri and gemini.showUrl(uri) or "gemini://",
1615 buttons = {
1618 text_func = function() return advanced and _("Edit identity URLs") or _("Identity") end,
1619 id = "ident",
1620 callback = function()
1621 close_nav_keyboard()
1622 self:confIdentAt(current_nav_url(), function()
1623 update_buttons()
1624 end)
1625 end,
1626 hold_callback = function()
1627 close_nav_keyboard()
1628 self:editActiveIdentities()
1629 end,
1632 text_func = function() return advanced and _("View as text") or _("Page info") end,
1633 id = "info",
1634 callback = function()
1635 UIManager:close(nav)
1636 self:showCurrentInfo()
1637 end,
1638 hold_callback = function()
1639 UIManager:close(nav)
1640 self:viewCurrentAsText()
1641 end,
1646 text_func = function() return advanced and _("History") or _("Back") end,
1647 enabled = #history > 1,
1648 callback = function()
1649 UIManager:close(nav)
1650 self:goBack()
1651 end,
1652 hold_callback = show_hist,
1655 text_func = function() return advanced and _("History") or _("Unback") end,
1656 enabled = #unhistory > 0,
1657 callback = function()
1658 UIManager:close(nav)
1659 self:goBack(-1)
1660 end,
1661 hold_callback = show_hist,
1664 text_func = function() return advanced and _("Edit queue") or _("Next") end,
1665 enabled = #queue > 0,
1666 callback = function()
1667 UIManager:close(nav)
1668 self:openQueueItem()
1669 end,
1670 hold_callback = function()
1671 UIManager:close(nav)
1672 self:editQueue()
1673 end,
1676 text_func = function() return advanced and _("Edit marks") or _("Bookmarks") end,
1677 callback = function()
1678 UIManager:close(nav)
1679 self:openUrl("about:bookmarks")
1680 end,
1681 hold_callback = function()
1682 if self.ui.texteditor and self.ui.texteditor.quickEditFile then
1683 UIManager:close(nav)
1684 self:writeDefaultBookmarks()
1685 local function done_cb()
1686 if self:purl() and url.build(self:purl()) == "about:bookmarks" then
1687 self:openUrl("about:bookmarks", { replace_history = true })
1690 self.ui.texteditor:quickEditFile(marks_path, done_cb, true)
1691 else
1692 UIManager:show(InfoMessage:new{text = T(_([[
1693 Can't load TextEditor: Plugin disabled or incompatible.
1694 To edit bookmarks, please edit the file %1 in the koreader directory manually.
1695 ]]), marks_path)})
1697 end,
1702 text_func = function() return advanced and _("Root") or _("Up") end,
1703 callback = function()
1704 local u = current_nav_url()
1705 local up = url.absolute(u, "./")
1706 if up == u then
1707 up = url.absolute(u, "../")
1709 nav:setInputText(up)
1710 update_buttons()
1711 end,
1712 hold_callback = function()
1713 nav:setInputText(url.absolute(current_nav_url(), "/"))
1714 update_buttons()
1715 end,
1718 text = _("Save"),
1719 id = "save",
1720 callback = function()
1721 local u = current_nav_url()
1722 local purl = url.parse(u)
1723 if purl and purl.scheme and purl.scheme == "about" then
1724 UIManager:show(InfoMessage:new{text = _("Can't save about: pages")})
1725 elseif u == gemini.showUrl(self:purl()) then
1726 UIManager:close(nav)
1727 self:saveCurrent()
1728 else
1729 UIManager:close(nav)
1730 self:openUrl(u, { body_cb = function(f, mimetype, p2)
1731 self:saveBody(f, mimetype, p2)
1732 end })
1734 end,
1737 text_func = function() return advanced and _("Prepend") or _("Add") end,
1738 callback = queue_nav_url,
1739 hold_callback = function() queue_nav_url(true) end,
1742 text_func = function() return advanced and _("Quick mark") or _("Mark") end,
1743 callback = function()
1744 if current_input_nonempty() then
1745 self:addMarkInteractive(current_nav_url())
1746 UIManager:close(nav)
1748 end,
1749 hold_callback = function()
1750 if current_input_nonempty()
1751 and self:addMark(current_nav_url()) then
1752 UIManager:close(nav)
1754 end,
1759 text = _("Cancel"),
1760 id = "close",
1761 callback = function()
1762 UIManager:close(nav)
1763 end,
1766 text_func = function() return advanced and _("Input") or _("Go") end,
1767 is_enter_default = true,
1768 callback = function()
1769 UIManager:close(nav)
1770 local u = current_nav_url()
1771 self:openUrl(u, { after_err_cb = function() self:showNav(u, true) end})
1772 end,
1773 hold_callback = function()
1774 local purl = url.parse(current_nav_url())
1775 if purl then
1776 self:userPromptInput(purl)
1777 UIManager:close(nav)
1779 end,
1784 update_buttons()
1785 -- FIXME: less hacky way to do this?
1786 nav._input_widget.edit_callback = function(edited)
1787 if edited then
1788 update_buttons()
1792 nav.title_bar.right_icon = "appbar.settings"
1793 nav.title_bar.right_icon_tap_callback = toggle_advanced
1794 nav.title_bar:init()
1796 UIManager:show(nav)
1797 if showKbd then
1798 nav:onShowKeyboard()
1802 function GeminiPlugin:onDispatcherRegisterActions()
1803 Dispatcher:registerAction("browse_gemini", {category = "none", event = "BrowseGemini", title = _("Browse Gemini"), general = true, separator = true })
1804 Dispatcher:registerAction("gemini_back", {category = "none", event = "GeminiBack", title = _("Gemini: Back"), reader = true })
1805 Dispatcher:registerAction("gemini_unback", {category = "none", event = "GeminiUnback", title = _("Gemini: Unback"), reader = true })
1806 Dispatcher:registerAction("gemini_history", {category = "none", event = "GeminiHistory", title = _("Gemini: History"), reader = true })
1807 Dispatcher:registerAction("gemini_bookmarks", {category = "none", event = "GeminiBookmarks", title = _("Gemini: Bookmarks"), reader = true })
1808 Dispatcher:registerAction("gemini_mark", {category = "none", event = "GeminiMark", title = _("Gemini: Mark"), reader = true })
1809 Dispatcher:registerAction("gemini_next", {category = "none", event = "GeminiNext", title = _("Gemini: Next"), reader = true })
1810 Dispatcher:registerAction("gemini_add", {category = "none", event = "GeminiAdd", title = _("Gemini: Add"), reader = true })
1811 Dispatcher:registerAction("gemini_nav", {category = "none", event = "GeminiNav", title = _("Gemini: Open nav"), reader = true })
1812 Dispatcher:registerAction("gemini_input", {category = "none", event = "GeminiInput", title = _("Gemini: Input"), reader = true })
1813 Dispatcher:registerAction("gemini_reload", {category = "none", event = "GeminiReload", title = _("Gemini: Reload"), reader = true })
1814 Dispatcher:registerAction("gemini_up", {category = "none", event = "GeminiUp", title = _("Gemini: Up"), reader = true })
1815 Dispatcher:registerAction("gemini_goNew", {category = "none", event = "GeminiGoNew", title = _("Gemini: Enter URL"), reader = true, separator = true })
1818 function GeminiPlugin:onBrowseGemini()
1819 if self.active then
1820 self:showNav()
1821 elseif #history > 0 then
1822 self:openCurrent()
1823 elseif G_reader_settings:nilOrFalse("gemini_initiated") then
1824 self:openUrl("about:welcome")
1825 else
1826 self:openUrl("about:bookmarks")
1828 return true
1831 function GeminiPlugin:onGeminiBack()
1832 if self.active then
1833 self:goBack()
1834 return true
1837 function GeminiPlugin:onGeminiUnback()
1838 if self.active then
1839 self:goBack(-1)
1840 return true
1843 function GeminiPlugin:onGeminiHistory()
1844 if self.active then
1845 self:showHistoryMenu()
1846 return true
1849 function GeminiPlugin:onGeminiBookmarks()
1850 self:openUrl("about:bookmarks")
1851 return true
1853 function GeminiPlugin:onGeminiMark()
1854 if self.active then
1855 self:addMarkInteractive(gemini.showUrl(self:purl()))
1856 return true
1859 function GeminiPlugin:onGeminiNext()
1860 self:openQueueItem()
1861 return true
1863 function GeminiPlugin:onGeminiAdd()
1864 if self.active then
1865 self:queueCachedHistoryItem(history[1])
1866 return true
1869 function GeminiPlugin:onGeminiInput()
1870 if self.active then
1871 self:userPromptInput(history[1].purl)
1872 return true
1875 function GeminiPlugin:onGeminiReload()
1876 if self.active then
1877 self:openUrl(history[1].purl, { replace_history = true })
1878 return true
1881 function GeminiPlugin:onGeminiUp()
1882 if self.active then
1883 local u = gemini.showUrl(self:purl())
1884 local up = url.absolute(u, "./")
1885 if up == u then
1886 up = url.absolute(u, "../")
1888 if up ~= u then
1889 self:openUrl(up)
1891 return true
1894 function GeminiPlugin:onGeminiGoNew()
1895 self:showNav("")
1896 return true
1898 function GeminiPlugin:onGeminiNav()
1899 self:showNav()
1900 return true
1903 function GeminiPlugin:addToMainMenu(menu_items)
1904 menu_items.gemini = {
1905 sorting_hint = "search",
1906 text = _("Browse Gemini"),
1907 callback = function()
1908 self:onBrowseGemini()
1909 end,
1911 local hint = "search_settings"
1912 if Version:getNormalizedCurrentVersion() < 202305180000 then
1913 -- backwards compatibility
1914 hint = "search"
1916 menu_items.gemini_settings = {
1917 text = _("Gemini settings"),
1918 sorting_hint = hint,
1919 sub_item_table = {
1921 text = _("Show help"),
1922 callback = function()
1923 self:openUrl("about:help")
1924 end,
1927 text = T(_("Max cached history items: %1"), max_cache_history_items),
1928 help_text = _("History items up to this limit will be stored on the filesystem and can be accessed offline with Back."),
1929 keep_menu_open = true,
1930 callback = function(touchmenu_instance)
1931 local widget = SpinWidget:new{
1932 title_text = _("Max cached history items"),
1933 value = max_cache_history_items,
1934 value_min = 0,
1935 value_max = 200,
1936 default_value = default_max_cache_history_items,
1937 callback = function(spin)
1938 max_cache_history_items = spin.value
1939 G_reader_settings:saveSetting("gemini_max_cache_history_items", spin.value)
1940 touchmenu_instance:updateItems()
1941 end,
1943 UIManager:show(widget)
1944 end,
1947 text = _("Set directory for saved documents"),
1948 keep_menu_open = true,
1949 callback = function()
1950 local title_header = _("Current directory for saved gemini documents:")
1951 local current_path = save_dir
1952 local default_path = getDefaultSavesDir()
1953 local function caller_callback(path)
1954 save_dir = path
1955 G_reader_settings:saveSetting("gemini_save_dir", path)
1956 if not util.pathExists(path) then
1957 lfs.mkdir(path)
1960 filemanagerutil.showChooseDialog(title_header, caller_callback, current_path, default_path)
1961 end,
1964 text = _("Configure scheme proxies"),
1965 help_text = _("Configure proxy servers to use for non-gemini URL schemes."),
1966 callback = function()
1967 self:schemeProxyMenu()
1968 end,
1971 text = _("Disable fetch on add"),
1972 help_text = _("Disables immediately fetching URLs added to the queue when connected."),
1973 checked_func = function()
1974 return G_reader_settings:isTrue("gemini_no_fetch_on_add")
1975 end,
1976 callback = function()
1977 G_reader_settings:flipNilOrFalse("gemini_no_fetch_on_add")
1978 end,
1981 text = _("Confirm certificates for new hosts"),
1982 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."),
1983 checked_func = function()
1984 return G_reader_settings:isTrue("gemini_confirm_tofu")
1985 end,
1986 callback = function()
1987 G_reader_settings:flipNilOrFalse("gemini_confirm_tofu")
1988 end,
1994 return GeminiPlugin