factor out SchemeProxies
[gemini.koplugin.git] / client.lua
blob05ececed84df44b1d4fe926b4df9b0c24ce7d1b7
1 local gemini = require("gemini")
2 local Identities = require("identities")
3 local SchemeProxies = require("schemeproxies")
5 local BD = require("ui/bidi")
6 local WidgetContainer = require("ui/widget/container/widgetcontainer")
7 local Event = require("ui/event")
8 local Device = require("device")
9 local UIManager = require("ui/uimanager")
10 local ConfirmBox = require("ui/widget/confirmbox")
11 local InputDialog = require("ui/widget/inputdialog")
12 local MultiInputDialog = require("ui/widget/multiinputdialog")
13 local FileManager = require("apps/filemanager/filemanager")
14 local DocSettings = require("docsettings")
15 local DataStorage = require("datastorage")
16 local ReadHistory = require("readhistory")
17 local Trapper = require("ui/trapper")
18 local InfoMessage = require("ui/widget/infomessage")
19 local CheckButton = require("ui/widget/checkbutton")
20 local MultiConfirmBox = require("ui/widget/multiconfirmbox")
21 local SpinWidget = require("ui/widget/spinwidget")
22 local Menu = require("ui/widget/menu")
23 local Persist = require("persist")
24 local NetworkMgr = require("ui/network/manager")
25 local DocumentRegistry = require("document/documentregistry")
26 local Font = require("ui/font")
27 local TextViewer = require("ui/widget/textviewer")
28 local KeyValuePage = require("ui/widget/keyvaluepage")
29 local Screen = require("device").screen
30 local Version = require("version")
31 local filemanagerutil = require("apps/filemanager/filemanagerutil")
32 local lfs = require("libs/libkoreader-lfs")
33 local url = require("socket.url")
34 local sha256 = require("ffi/sha2").sha256
35 local util = require("util")
36 local ffiutil = require("ffi/util")
37 local _ = require("gettext")
38 local T = require("ffi/util").template
40 local gemini_dir = DataStorage:getDataDir() .. "/gemini"
41 local history_dir = "/tmp/gemini-history"
42 local queue_dir = "/tmp/gemini-queue"
43 local marks_path = gemini_dir .. "/bookmarks.gmi"
44 --local cafile_path = DataStorage:getDataDir() .. "/data/ca-bundle.crt"
46 local function getDefaultSavesDir()
47 local dir = G_reader_settings:readSetting("home_dir") or filemanagerutil.getDefaultDir()
48 if dir:sub(-1) ~= "/" then
49 dir = dir .. "/"
50 end
51 return dir .. "downloaded"
52 end
53 local save_dir = G_reader_settings:readSetting("gemini_save_dir") or getDefaultSavesDir()
55 local queue_persist = Persist:new{ path = gemini_dir .. "/queue.lua" }
56 local trust_store_persist = Persist:new{ path = gemini_dir .. "/known_hosts.lua" }
58 local Client = WidgetContainer:extend{
59 active = false, -- are we currently browsing gemini
60 activated = false, -- are we about to be browsing gemini at next init
62 hold_pan = false,
64 repl_purl = nil, -- parsed URL to request input at after init
66 history = {},
67 unhistory = {},
68 trust_overrides = {},
70 max_input_history = 10,
71 input_history = {},
73 queue = queue_persist:load() or {},
74 trust_store = trust_store_persist:load() or {},
77 local default_max_cache_history_items = 20
78 local max_cache_history_items = G_reader_settings:readSetting("gemini_max_cache_history_items") or default_max_cache_history_items
81 -- Parsed URL of current item
82 function Client:purl()
83 if #self.history > 0 then
84 return self.history[1].purl
85 end
86 end
88 function Client:init(ui)
89 self.ui = ui
90 self.active = self.activated
91 self.activated = false
92 if self.active then
93 assert(self:purl())
94 local postcb = self.ui.registerPostReaderReadyCallback or
95 self.ui.registerPostReadyCallback
96 -- Name for the callback in versions <= 2024.04
97 if postcb then
98 if self.ui.document.file then
99 -- Keep gemini history files out of reader history
100 postcb(self.ui, function ()
101 ReadHistory:removeItemByPath(self.ui.document.file)
102 end)
104 if self.repl_purl then
105 local local_repl_purl = self.repl_purl
106 postcb(self.ui, function ()
107 -- XXX: Input widget painted over without this delay. Better way?
108 UIManager:scheduleIn(0.1, function ()
109 self:promptInput(local_repl_purl, "[Repeating]", false, true)
110 end)
111 end)
115 self.repl_purl = nil
117 if self.ui and self.ui.link then
118 if self.ui.link.registerScheme then
119 for _,scheme in ipairs(SchemeProxies:supportedSchemes()) do
120 self.ui.link:registerScheme(scheme)
122 if self.active then
123 self.ui.link:registerScheme("")
126 self.ui.link:addToExternalLinkDialog("23_gemini", function(this, link_url)
127 return {
128 text = _("Open via Gemini"),
129 callback = function()
130 UIManager:close(this.external_link_dialog)
131 this.ui:handleEvent(Event:new("FollowGeminiLink", link_url))
132 end,
133 show_in_dialog_func = function(u)
134 local scheme = u:match("^(%w[%w+%-.]*):") or ""
135 if scheme == "" and self.active then
136 return true
138 return util.arrayContains(SchemeProxies:supportedSchemes(), scheme)
139 end,
141 end)
144 self.ui.menu:registerToMainMenu(self)
146 if self.ui and self.ui.highlight then
147 self.ui.highlight:addToHighlightDialog("20_queue_links", function(this)
148 return {
149 text = _("Add links to queue"),
150 show_in_highlight_dialog_func = function()
151 return self.active
152 end,
153 callback = function()
154 self:queueLinksInSelected(this.selected_text)
155 this:onClose()
156 end,
158 end)
161 if self.active and Device:isTouchDevice() then
162 self.ui:registerTouchZones({
164 id = "tap_link_gemini",
165 ges = "tap",
166 screen_zone = {
167 ratio_x = 0, ratio_y = 0,
168 ratio_w = 1, ratio_h = 1,
170 overrides = {
171 -- Tap on gemini links has priority over everything
172 "tap_link",
173 "readerhighlight_tap",
174 "tap_top_left_corner",
175 "tap_top_right_corner",
176 "tap_left_bottom_corner",
177 "tap_right_bottom_corner",
178 "readerfooter_tap",
179 "readerconfigmenu_ext_tap",
180 "readerconfigmenu_tap",
181 "readermenu_ext_tap",
182 "readermenu_tap",
183 "tap_forward",
184 "tap_backward",
186 handler = function(ges) return self:onTap(nil, ges) end,
189 id = "hold_release_link_gemini",
190 ges = "hold_release",
191 screen_zone = {
192 ratio_x = 0, ratio_y = 0,
193 ratio_w = 1, ratio_h = 1,
195 overrides = {
196 "readerhighlight_hold_release",
198 handler = function(ges) return self:onHoldRelease(nil, ges) end,
201 id = "hold_pan_link_gemini",
202 ges = "hold_pan",
203 screen_zone = {
204 ratio_x = 0, ratio_y = 0,
205 ratio_w = 1, ratio_h = 1,
207 overrides = {
208 "readerhighlight_hold_pan",
210 handler = function(ges) return self:onHoldPan(nil, ges) end,
213 id = "double_tap_link_gemini",
214 ges = "double_tap",
215 screen_zone = {
216 ratio_x = 0, ratio_y = 0,
217 ratio_w = 1, ratio_h = 1,
219 overrides = {
220 "double_tap_top_left_corner",
221 "double_tap_top_right_corner",
222 "double_tap_bottom_left_corner",
223 "double_tap_bottom_right_corner",
224 "double_tap_left_side",
225 "double_tap_right_side",
227 handler = function(ges) return self:onDoubleTap(nil, ges) end,
230 id = "swipe_gemini",
231 ges = "swipe",
232 screen_zone = {
233 ratio_x = 0, ratio_y = 0,
234 ratio_w = 1, ratio_h = 1,
236 handler = function(ges) return self:onSwipe(nil, ges) end,
242 function Client:mimeToExt(mimetype)
243 return (mimetype == "text/plain" and "txt")
244 or DocumentRegistry:mimeToExt(mimetype)
245 or (mimetype:find("^text/") and "txt")
248 local function writeBodyToFile(body, path)
249 local o = io.open(path, "w")
250 if o then
251 if type(body) == "string" then
252 o:write(body)
253 o:close()
254 return true
255 else
256 local chunk, aborted = body:read(256)
257 while chunk and chunk ~= "" do
258 o:write(chunk)
259 chunk, aborted = body:read(256)
261 body:close()
262 o:close()
263 return not aborted
265 else
266 return false
270 function Client:saveBody(body, mimetype, purl)
271 self:getSavePath(purl, mimetype, function(path)
272 if not writeBodyToFile(body, path) then
273 -- clear up partial write
274 FileManager:deleteFile(path, true)
276 end)
279 function Client:openBody(body, mimetype, purl, cert_info, replace_history)
280 util.makePath(history_dir)
282 local function get_ext(p)
283 if p.path then
284 local ext, m = p.path:gsub(".*%.","",1)
285 if m == 1 then
286 return ext
290 local hn = #self.history
291 if replace_history then
292 hn = hn - 1
294 local tn = history_dir .. "/Gemini " .. hn
295 local ext = self:mimeToExt(mimetype) or get_ext(purl)
296 if ext then
297 tn = tn .. "." .. ext
300 if not DocumentRegistry:hasProvider(tn) then
301 UIManager:show(ConfirmBox:new{
302 text = T(_("Can't view file (%1). Save it instead?"), mimetype),
303 ok_text = _("Save file"),
304 cancel_text = _("Cancel"),
305 ok_callback = function()
306 self:saveBody(body, mimetype, purl)
307 end,
309 return
312 if not replace_history then
313 -- Delete history tail
314 local ok, iter, dir_obj = pcall(lfs.dir, history_dir)
315 if not ok then
316 return
319 for f in iter, dir_obj do
320 local path = history_dir.."/"..f
321 local attr = lfs.attributes(path) or {}
322 if attr.mode == "file" or attr.mode == "link" then
323 local n = tonumber(f:match("^Gemini (%d+)%f[^%d]"))
324 if n and (n >= #self.history or n <= #self.history - max_cache_history_items) then
325 FileManager:deleteFile(path, true)
326 local h = self.history[#self.history - n]
327 if h and h.path == path then
328 h.path = nil
333 while table.remove(self.unhistory) do end
336 if not writeBodyToFile(body, tn) then
337 return
340 local history_item = { purl = purl, path = tn, mimetype = mimetype, cert_info = cert_info }
341 if replace_history then
342 self.history[1] = history_item
343 else
344 table.insert(self.history, 1, history_item)
347 self:openCurrent()
350 function Client:openCurrent()
351 if not (self.history[1].path and util.fileExists(self.history[1].path)) then
352 return self:openUrl(self.history[1].purl, { replace_history = true })
355 -- as in ReaderUI:switchDocument, but with seamless option
356 local function switchDocumentSeamlessly(new_file)
357 -- Mimic onShowingReader's refresh optimizations
358 self.ui.tearing_down = true
359 self.ui.dithered = nil
361 self.ui:handleEvent(Event:new("CloseReaderMenu"))
362 self.ui:handleEvent(Event:new("CloseConfigMenu"))
363 self.ui.highlight:onClose() -- close highlight dialog if any
364 self.ui:onClose(false)
366 self.ui:showReader(new_file, nil, true)
369 local open_msg = InfoMessage:new{
370 text = T(_("%1\nOpening..."), gemini.showUrl(self.history[1].purl, true)),
372 UIManager:show(open_msg)
374 self.activated = true
376 if self.ui.name == "ReaderUI" then
377 --self.ui:switchDocument(history[1].path)
378 switchDocumentSeamlessly(self.history[1].path)
379 else
380 local ReaderUI = require("apps/reader/readerui")
381 ReaderUI:showReader(self.history[1].path, nil, true)
383 UIManager:close(open_msg)
386 function Client:writeDefaultBookmarks()
387 if not util.fileExists(marks_path) then
388 local f = io.open(marks_path, "w")
389 if f then
390 f:write(require("staticgemtexts").default_bookmarks)
391 f:close()
396 function Client:openUrl(article_url, opts)
397 if type(article_url) ~= "string" then
398 article_url = url.build(article_url)
400 opts = opts or {}
401 local body_cb = opts.body_cb or function(f, mimetype, p, cert_info)
402 self:openBody(f, mimetype, p, cert_info, opts.replace_history)
404 local after_err_cb = opts.after_err_cb or function() end
405 if self:purl() then
406 article_url = url.absolute(self:purl(), article_url)
409 local purl = url.parse(article_url, {port = "1965"})
411 if purl and purl.scheme == "about" then
412 local body
413 if purl.path == "bookmarks" then
414 self:writeDefaultBookmarks()
415 G_reader_settings:makeTrue("gemini_initiated")
416 body = io.open(marks_path, "r")
417 else
418 body = require("staticgemtexts")[purl.path]
420 if body then
421 body_cb(body, "text/gemini", purl)
422 else
423 UIManager:show(InfoMessage:new{text = T(_("Unknown \"about:\" URL: %1"), article_url)})
425 return
428 if not purl or not purl.host then
429 UIManager:show(InfoMessage:new{text = T(_("Invalid URL: %1"), article_url)})
430 return
433 local proxy = SchemeProxies:get(purl.scheme)
434 if purl.scheme ~= "gemini" and not proxy then
435 UIManager:show(InfoMessage:new{text = T(_("No proxy configured for scheme: %1"), purl.scheme)})
436 return
439 if NetworkMgr:willRerunWhenConnected(function() self:openUrl(article_url, opts) end) then
440 -- Not connected yet, nothing more to do here, NetworkMgr will forward the callback and run it once connected!
441 return
444 local id, __, id_path = Identities:get(article_url)
445 local function success_cb(f, p, mimetype, params, cert_info)
446 if opts.repl_purl then
447 self.repl_purl = opts.repl_purl
449 body_cb(f, mimetype, p, cert_info)
451 local function error_cb(msg, p, major, minor, server_msg)
452 if major then
453 msg = T(_("Server reports %1: %2"), msg, server_msg)
455 if major == "1" then
456 self:promptInput(p, server_msg, minor == "1")
457 elseif major == "6" then
458 UIManager:show(ConfirmBox:new{
459 text = msg,
460 ok_text = _("Set identity"),
461 cancel_text = _("Cancel"),
462 ok_callback = function()
463 Identities:confAt(gemini.showUrl(p), function(new_id)
464 if new_id then
465 self:openUrl(p, opts)
467 end)
468 end,
470 else
471 UIManager:show(InfoMessage:new{
472 text = msg,
473 dismiss_callback = after_err_cb,
477 local function check_trust_cb(host, new_fp, old_trusted_times, old_expiry, cb)
478 if self.trust_overrides[new_fp] and os.time() < self.trust_overrides[new_fp] then
479 cb("once")
480 else
481 self:promptUnexpectedCert(host, new_fp, old_trusted_times, old_expiry, cb)
484 local function trust_modified_cb()
485 trust_store_persist:save(self.trust_store)
487 local perm_redir_cb = nil -- TODO: automatically edit bookmarks file?
488 local function info_cb(msg, fast)
489 return Trapper:info(msg, fast)
492 Trapper:wrap(function()
493 Trapper:setPausedText(T(_("Abort connection?")))
494 gemini.makeRequest(gemini.showUrl(purl),
495 id and id_path..".key",
496 id and id_path..".crt",
497 nil, -- disable CA-based verification
498 self.trust_store,
499 check_trust_cb,
500 trust_modified_cb,
501 success_cb,
502 error_cb,
503 perm_redir_cb,
504 info_cb,
505 G_reader_settings:isTrue("gemini_confirm_tofu"),
506 proxy and proxy.host)
507 Trapper:reset()
508 end)
511 -- Prompt user for input. May modify `purl.query`.
512 function Client:promptInput(purl, prompt, is_secret, repl, initial)
513 local input_dialog
514 local repl_button
515 local multiline_button
516 local function submit()
517 local input = input_dialog:getInputText()
518 purl.query = url.escape(input)
519 if #url.build(purl) > 1024 then
520 UIManager:show(InfoMessage:new{ text =
521 T(_("Input too long (by %1 bytes)"), #url.build(purl) - 1024) })
522 else
523 UIManager:close(input_dialog)
524 table.insert(self.input_history, 1, input)
525 if #self.input_history > self.max_input_history then
526 table.remove(self.input_history, #self.input_history)
528 self:openUrl(purl, {
529 repl_purl = repl_button.checked and purl,
530 after_err_cb = function() self:promptInput(purl, prompt, is_secret, repl, input) end,
534 local hi = 0
535 local latest_input
536 local function update_buttons()
537 local prev_button = input_dialog.button_table:getButtonById("prev")
538 prev_button:enableDisable(#self.input_history > hi)
539 UIManager:setDirty(prev_button, "ui")
540 local next_button = input_dialog.button_table:getButtonById("next")
541 next_button:enableDisable(hi > 0)
542 UIManager:setDirty(next_button, "ui")
544 local function to_hist(i)
545 if hi == 0 then
546 latest_input = input_dialog:getInputText()
548 hi = i
549 input_dialog:setInputText(hi > 0 and self.input_history[hi] or latest_input)
550 update_buttons()
552 input_dialog = InputDialog:new{
553 title = prompt,
554 input = initial or "",
555 text_type = is_secret and "password",
556 enter_callback = submit,
557 buttons = {
560 text = _("Cancel"),
561 id = "close",
562 callback = function()
563 UIManager:close(input_dialog)
564 end,
567 icon = "move.up",
568 id = "prev",
569 callback = function() to_hist(hi+1) end,
570 hold_callback = function() to_hist(#self.input_history) end
573 icon = "move.down",
574 id = "next",
575 callback = function() to_hist(hi-1) end,
576 hold_callback = function() to_hist(0) end
579 text = _("Enter"),
580 callback = submit,
585 update_buttons()
587 -- read-eval-print-loop mode: keep presenting input dialog
588 repl_button = CheckButton:new{
589 text = _("Repeat"),
590 checked = repl,
591 parent = input_dialog,
593 multiline_button = CheckButton:new{
594 text = _("Multiline input"),
595 checked = false,
596 callback = function()
597 input_dialog.allow_newline = multiline_button.checked
598 -- FIXME: less hacky way to do this?
599 if multiline_button.checked then
600 input_dialog._input_widget.enter_callback = nil
601 else
602 input_dialog._input_widget.enter_callback = submit
604 end,
605 parent = input_dialog,
607 input_dialog:addWidget(multiline_button)
608 input_dialog:addWidget(repl_button)
610 local y_offset = 0
611 if repl then
612 -- Draw just above keyboard (in vertical mode),
613 -- so we can see as much as possible of the newly loaded page
614 y_offset = Screen:scaleBySize(120)
615 if G_reader_settings:isTrue("keyboard_key_compact") then
616 y_offset = y_offset + 50
620 UIManager:show(input_dialog, nil, nil, nil, y_offset)
621 input_dialog:onShowKeyboard()
622 return true
625 function Client:userPromptInput(purl)
626 self:promptInput(purl, "[Input]", false, false, url.unescape(purl.query or ""))
629 function Client:promptUnexpectedCert(host, new_fp, old_trusted_times, old_expiry, cb)
630 local widget = MultiConfirmBox:new{
631 text = old_trusted_times > 0 and
632 T(_([[
633 The server identity presented by %1 does not match that previously trusted (%2 times).
634 Digest of received certificate: SHA256:%3
635 Previously trusted certificate expiry date: %4]]), host, old_trusted_times, new_fp, old_expiry) or
636 T(_([[
637 No trusted server identity known for %1. Trust provided server identity?
638 Digest of received certificate: SHA256:%2]]), host, new_fp),
639 face = Font:getFace("x_smallinfofont"),
640 choice1_text = _("Trust new certificate"),
641 choice1_callback = function()
642 cb("always")
643 end,
644 choice2_text = _("Connect without trust"),
645 choice2_callback = function()
646 -- persist for 1h
647 self.trust_overrides[new_fp] = os.time() + 3600
648 cb("once")
649 end,
650 cancel_callback = function()
651 cb()
652 end,
654 UIManager:show(widget)
657 function Client:goBack(n)
658 n = n or 1
659 if n > #self.history-1 then
660 n = #self.history-1
661 elseif n < -#self.unhistory then
662 n = -#self.unhistory
664 if n == 0 then
665 return false
667 while n > 0 do
668 table.insert(self.unhistory, 1, table.remove(self.history, 1))
669 n = n-1
671 while n < 0 do
672 table.insert(self.history, 1, table.remove(self.unhistory, 1))
673 n = n+1
675 self:openCurrent()
676 return true
679 function Client:clearHistory()
680 local function delete_item(item)
681 if item.path then
682 FileManager:deleteFile(item.path, true)
686 while #self.history > 1 do
687 delete_item(table.remove(self.history, 2))
689 while #self.unhistory > 0 do
690 delete_item(table.remove(self.unhistory, 1))
694 function Client:onTap(_, ges)
695 if self.active then
696 return self:followGesLink(ges)
700 function Client:onDoubleTap(_, ges)
701 if self.active then
702 return self:followGesLink(ges, true)
706 function Client:onHoldPan(_, ges)
707 self.hold_pan = true
710 function Client:onHoldRelease(_, ges)
711 if self.active and not self.hold_pan then
712 if self:followGesLink(ges, true) then
713 if self.ui.highlight then
714 self.ui.highlight:clear()
716 return true
719 self.hold_pan = false
722 function Client:onSwipe(_, ges)
723 if self.active then
724 local direction = BD.flipDirectionIfMirroredUILayout(ges.direction)
725 if direction == "south" then
726 return self:goBack()
727 elseif direction == "north" then
728 self:showNav()
729 return true
734 function Client:followGesLink(ges, nav)
735 local link = self.ui.link:getLinkFromGes(ges)
736 if link and link.xpointer then
737 local scheme = link.xpointer:match("^(%w[%w+%-.]*):") or ""
738 if util.arrayContains(SchemeProxies:supportedSchemes(), scheme)
739 or (scheme == "" and self.active) then
740 if nav then
741 self:showNav(link.xpointer)
742 else
743 self:openUrl(link.xpointer)
745 return true
750 function Client:onFollowGeminiLink(u)
751 return self:showNav(u)
754 function Client:onEndOfBook()
755 -- TODO: seems we can't override the usual reader onEndOfBook handling.
756 -- Leaving this as a hidden option for now.
757 if G_reader_settings:isTrue("gemini_next_on_end") then
758 if self.active and #self.queue > 0 then
759 self:openQueueItem()
760 return true
765 function Client:queueLinksInSelected(selected)
766 local html = self.ui.document:getHTMLFromXPointers(selected.pos0, selected.pos1, nil, true)
767 if html then
768 -- Following pattern isn't strictly correct in general,
769 -- but is for the html generated from a gemini document.
770 local n = 0
771 for u in html:gmatch('<a[^>]*href="([^"]*)"') do
772 self:queueLink(u)
773 n = n + 1
775 UIManager:show(InfoMessage:new{ text =
776 n == 0 and _("No links found in selected text.") or
777 T(_("Added %1 links to queue."), n)
782 function Client:queueBody(body, u, mimetype, cert_info, existing_item, prepend)
783 util.makePath(queue_dir)
784 local path = queue_dir.."/"..sha256(u)
785 if writeBodyToFile(body, path) then
786 if existing_item then
787 existing_item.path = path
788 existing_item.mimetype = mimetype
789 existing_item.cert_info = cert_info
790 else
791 self:queueItem({ url = u, path = path, mimetype = mimetype, cert_info = cert_info }, prepend)
793 elseif not existing_item then
794 self:queueItem({ url = u }, prepend)
798 function Client:queueCachedHistoryItem(h, prepend)
799 local body = io.open(h.path, "r")
800 if body then
801 self:queueBody(body, gemini.showUrl(h.purl), h.mimetype, h.cert_info, nil, prepend)
805 function Client:fetchLink(u, item, prepend)
806 self:openUrl(u, { body_cb = function(body, mimetype, purl, cert_info)
807 self:queueBody(body, gemini.showUrl(purl), mimetype, cert_info, item, prepend)
808 end})
811 function Client:fetchQueue()
812 for _n,item in ipairs(self.queue) do
813 if not item.path then
814 self:fetchLink(item.url, item)
819 function Client:queueLink(u, prepend)
820 local purl = url.parse(u)
821 if purl and purl.scheme ~= "about" and
822 not G_reader_settings:isTrue("gemini_no_fetch_on_add") and NetworkMgr:isConnected() then
823 self:fetchLink(u, nil, prepend)
824 else
825 self:queueItem({ url = u }, prepend)
829 function Client:queueItem(item, prepend)
830 for k = #self.queue,1,-1 do
831 if self.queue[k].url == item.url then
832 table.remove(self.queue,k)
835 if prepend then
836 table.insert(self.queue, 1, item)
837 else
838 table.insert(self.queue, item)
840 queue_persist:save(self.queue)
843 function Client:openQueueItem(n)
844 n = n or 1
845 local item = self.queue[n]
846 if item then
847 if item.path then
848 local f = io.open(item.path, "r")
849 if not f then
850 UIManager:show(InfoMessage:new{text = T(_("Failed to open %1 for reading."), item.path)})
851 else
852 self:openBody(f, item.mimetype, url.parse(item.url), item.cert_info)
853 FileManager:deleteFile(item.path, true)
854 self:popQueue(n)
856 elseif item.url:match("^about:") or NetworkMgr:isConnected() then
857 self:openUrl(item.url)
858 self:popQueue(n)
859 else
860 UIManager:show(InfoMessage:new{text = T(_("Need network connection to fetch %1"), item.url)})
865 function Client:popQueue(n)
866 n = n or 1
867 local item = table.remove(self.queue, n)
868 queue_persist:save(self.queue)
869 return item
872 function Client:clearQueue()
873 while #self.queue > 0 do
874 local item = table.remove(self.queue, 1)
875 if item.path then
876 FileManager:deleteFile(item.path, true)
879 queue_persist:save(self.queue)
882 function Client:getSavePath(purl, mimetype, cb)
883 local basename = ""
884 local add_ext = false
885 if purl.path then
886 basename = purl.path:gsub("/+$","",1):gsub(".*/","",1)
887 if basename == "" and purl.host then
888 basename = purl.host
889 add_ext = true
891 if add_ext or not basename:match(".+%..+") then
892 local ext = self:mimeToExt(mimetype)
893 if ext then
894 basename = basename.."."..ext
899 local widget
901 local function do_save()
902 local fields = widget:getFields()
903 local dir = fields[2]
904 local bn = fields[1]
905 if bn ~= "" then
906 local path = dir.."/"..bn
907 local tp = lfs.attributes(path, "mode")
908 if tp == "directory" then
909 UIManager:show(InfoMessage:new{text = _("Path is a directory")})
910 elseif tp ~= nil then
911 UIManager:show(ConfirmBox:new{
912 text = _("File exists. Overwrite?"),
913 ok_text = _("Overwrite"),
914 cancel_text = _("Cancel"),
915 ok_callback = function()
916 UIManager:close(widget)
917 cb(path)
918 end,
920 else
921 UIManager:close(widget)
922 util.makePath(dir)
923 cb(path)
928 widget = MultiInputDialog:new{
929 title = _("Save as"),
930 fields = {
932 description = _("Filename"),
933 text = basename,
936 description = _("Directory to save under"),
937 text = save_dir,
940 buttons = {
943 text = _("Cancel"),
944 id = "close",
945 callback = function()
946 UIManager:close(widget)
947 end,
950 text = _("Save"),
951 is_enter_default = true,
952 callback = do_save,
957 widget.input_field[1].enter_callback = do_save
958 widget.input_field[2].enter_callback = do_save
959 UIManager:show(widget)
960 widget:onShowKeyboard()
963 function Client:saveCurrent()
964 self:getSavePath(self.history[1].purl, self.history[1].mimetype, function(path)
965 ffiutil.copyFile(self.history[1].path, path)
966 self.ui:saveSettings()
967 if DocSettings.updateLocation then
968 DocSettings.updateLocation(self.history[1].path, path, true)
970 end)
973 function Client:addMark(u, desc)
974 if url.parse(u) then
975 self:writeDefaultBookmarks()
976 local line = "=> " .. u
977 if desc and desc ~= "" then
978 line = line .. " " .. desc
980 line = line .. "\n"
981 local f = io.open(marks_path, "a")
982 if f then
983 f:write(line)
984 f:close()
985 return true
990 function Client:addMarkInteractive(uri)
991 local widget
992 local function add_mark()
993 local fields = widget:getFields()
994 if self:addMark(fields[2], fields[1]) then
995 UIManager:close(widget)
998 widget = MultiInputDialog:new{
999 title = _("Add bookmark"),
1000 fields = {
1002 description = _("Description (optional)"),
1005 description = _("URL"),
1006 text = gemini.showUrl(uri),
1009 buttons = {
1012 text = _("Cancel"),
1013 id = "close",
1014 callback = function()
1015 UIManager:close(widget)
1016 end,
1019 text = _("Add"),
1020 is_enter_default = true,
1021 callback = add_mark,
1026 widget.input_field[1].enter_callback = add_mark
1027 widget.input_field[2].enter_callback = add_mark
1028 UIManager:show(widget)
1029 widget:onShowKeyboard()
1032 function Client:showHistoryMenu(cb)
1033 cb = cb or function(n) self:goBack(n) end
1034 local menu
1035 local history_items = {}
1036 local function show_history_item(h)
1037 return gemini.showUrl(h.purl) ..
1038 (h.path and " " .. _("(fetched)") or "")
1040 for n,h in ipairs(self.history) do
1041 table.insert(history_items, {
1042 text = T("%1 %2", n-1, show_history_item(h)),
1043 callback = function()
1044 cb(n-1)
1045 UIManager:close(menu)
1046 end,
1047 hold_callback = function()
1048 UIManager:close(menu)
1049 self:showNav(h.purl)
1050 end,
1053 for n,h in ipairs(self.unhistory) do
1054 table.insert(history_items, 1, {
1055 text = T("%1 %2", -n, show_history_item(h)),
1056 callback = function()
1057 cb(-n)
1058 UIManager:close(menu)
1062 if #history_items > 1 then
1063 table.insert(history_items, {
1064 text = _("Clear all history"),
1065 callback = function()
1066 UIManager:show(ConfirmBox:new{
1067 text = T(_("Clear %1 history items?"), #history_items-1),
1068 ok_text = _("Clear history"),
1069 cancel_text = _("Cancel"),
1070 ok_callback = function()
1071 self:clearHistory()
1072 UIManager:close(menu)
1073 end,
1078 menu = Menu:new{
1079 title = _("History"),
1080 item_table = history_items,
1081 onMenuHold = function(_, item)
1082 if item.hold_callback then
1083 item.hold_callback()
1085 end,
1086 width = Screen:getWidth(), -- backwards compatibility;
1087 height = Screen:getHeight(), -- can delete for KOReader >= 2023.04
1089 UIManager:show(menu)
1092 function Client:viewCurrentAsText()
1093 local h = self.history[1]
1094 local f = io.open(h.path,"r")
1095 UIManager:show(TextViewer:new{
1096 title = gemini.showUrl(h.purl),
1097 text = f and f:read("a") or "[Error reading file]"
1099 f:close()
1102 function Client:showCurrentInfo()
1103 local h = self.history[1]
1104 local kv_pairs = {
1105 { _("URL"), gemini.showUrl(h.purl) },
1106 { _("Mimetype"), h.mimetype }
1108 local widget
1109 if h.cert_info then
1110 table.insert(kv_pairs, "----")
1111 local cert_info = self.history[1].cert_info
1112 if cert_info.ca then
1113 table.insert(kv_pairs, { _("Trust type"), _("Chain to Certificate Authority") })
1114 for k, v in ipairs(cert_info.ca) do
1115 table.insert(kv_pairs, { v.name, v.value })
1117 else
1118 if cert_info.trusted_times > 0 then
1119 table.insert(kv_pairs, { _("Trust type"), _("Trust On First Use"), callback = function()
1120 UIManager:close(widget)
1121 self:openUrl("about:tofu")
1122 end })
1123 table.insert(kv_pairs, { _("SHA256 digest"), cert_info.fp })
1124 table.insert(kv_pairs, { _("Times seen"), cert_info.trusted_times })
1125 else
1126 table.insert(kv_pairs, { _("Trust type"), _("Temporarily accepted") })
1127 table.insert(kv_pairs, { _("SHA256 digest"), cert_info.fp })
1129 table.insert(kv_pairs, { _("Expiry date"), cert_info.expiry })
1132 table.insert(kv_pairs, "----")
1133 table.insert(kv_pairs, { "Source", _("Select to view page as text"), callback = function()
1134 self:viewCurrentAsText()
1135 end })
1136 widget = KeyValuePage:new{
1137 title = _("Page info"),
1138 kv_pairs = kv_pairs,
1140 UIManager:show(widget)
1143 function Client:editQueue()
1144 local menu
1145 local items = {}
1146 local function show_queue_item(item)
1147 return gemini.showUrl(item.url) ..
1148 (item.path and " " .. _("(fetched)") or "")
1150 local unfetched = 0
1151 for n,item in ipairs(self.queue) do
1152 table.insert(items, {
1153 text = n .. " " .. show_queue_item(item),
1154 callback = function()
1155 UIManager:close(menu)
1156 self:openQueueItem(n)
1157 end,
1158 hold_callback = function()
1159 UIManager:close(menu)
1160 self:showNav(item.url)
1161 end,
1163 if not item.path then
1164 unfetched = unfetched + 1
1167 if unfetched > 0 then
1168 table.insert(items, {
1169 text = T(_("Fetch %1 unfetched items"), unfetched),
1170 callback = function()
1171 self:fetchQueue()
1172 UIManager:close(menu)
1173 self:editQueue()
1177 if #items > 0 then
1178 table.insert(items, {
1179 text = _("Clear queue"),
1180 callback = function()
1181 UIManager:show(ConfirmBox:new{
1182 text = T(_("Clear %1 items from queue?"), #self.queue),
1183 ok_text = _("Clear queue"),
1184 cancel_text = _("Cancel"),
1185 ok_callback = function()
1186 self:clearQueue()
1187 UIManager:close(menu)
1188 end,
1193 menu = Menu:new{
1194 title = _("Queue"),
1195 item_table = items,
1196 onMenuHold = function(_, item)
1197 if item.hold_callback then
1198 item.hold_callback()
1200 end,
1201 width = Screen:getWidth(), -- backwards compatibility;
1202 height = Screen:getHeight(), -- can delete for KOReader >= 2023.04
1204 UIManager:show(menu)
1207 function Client:showNav(uri, showKbd)
1208 if uri and type(uri) ~= "string" then
1209 uri = url.build(uri)
1211 showKbd = showKbd or not uri or uri == ""
1212 if not uri then
1213 uri = gemini.showUrl(self:purl())
1214 elseif self:purl() and uri ~= "" then
1215 uri = url.absolute(self:purl(), uri)
1218 local nav
1219 local advanced = false
1220 local function current_nav_url()
1221 local u = nav:getInputText()
1222 if u:match("^[./?]") then
1223 -- explicitly relative url
1224 if self:purl() then
1225 u = url.absolute(self:purl(), u)
1227 else
1228 -- absolutise if necessary
1229 local purl = url.parse(u)
1230 if purl and purl.scheme == nil and purl.host == nil then
1231 u = "gemini://" .. u
1234 return u
1236 local function current_input_nonempty()
1237 local purl = url.parse(current_nav_url())
1238 return purl and (purl.host or purl.path)
1240 local function close_nav_keyboard()
1241 if nav.onCloseKeyboard then
1242 nav:onCloseKeyboard()
1243 elseif Version:getNormalizedCurrentVersion() < 202309010000 then
1244 -- backwards compatibility
1245 if nav._input_widget.onCloseKeyboard then
1246 nav._input_widget:onCloseKeyboard()
1250 local function show_hist()
1251 close_nav_keyboard()
1252 self:showHistoryMenu(function(n)
1253 UIManager:close(nav)
1254 self:goBack(n)
1255 end)
1257 local function queue_nav_url(prepend)
1258 if current_input_nonempty() then
1259 local u = current_nav_url()
1260 if u == gemini.showUrl(self:purl()) and self.history[1].path then
1261 self:queueCachedHistoryItem(self.history[1], prepend)
1262 else
1263 self:queueLink(u, prepend)
1265 UIManager:close(nav)
1268 local function update_buttons()
1269 local u = current_nav_url()
1270 local purl = url.parse(u)
1271 local id = Identities:get(u)
1272 local text = T(_("Identity: %1"), id or _("[none]"))
1273 local id_button = nav.button_table:getButtonById("ident")
1274 if not advanced then
1275 id_button:setText(text, id_button.width)
1277 id_button:enableDisable(advanced or (purl and purl.scheme == "gemini" and purl.host ~= ""))
1278 UIManager:setDirty(id_button, "ui")
1280 local save_button = nav.button_table:getButtonById("save")
1281 save_button:enableDisable(purl and purl.scheme and purl.scheme ~= "about")
1282 UIManager:setDirty(save_button, "ui")
1284 local info_button = nav.button_table:getButtonById("info")
1285 info_button:enableDisable(u == gemini.showUrl(self:purl()))
1286 UIManager:setDirty(info_button, "ui")
1288 local function toggle_advanced()
1289 advanced = not advanced
1290 for _,row in ipairs(nav.button_table.buttons_layout) do
1291 for _,button in ipairs(row) do
1292 if button.text_func and button.hold_callback then
1293 button:setText(button.text_func(), button.width)
1294 button.callback, button.hold_callback = button.hold_callback, button.callback
1298 update_buttons()
1299 UIManager:setDirty(nav, "ui")
1302 nav = InputDialog:new{
1303 title = _("Gemini navigation"),
1304 width = Screen:scaleBySize(550), -- in pixels
1305 input_type = "text",
1306 input = uri and gemini.showUrl(uri) or "gemini://",
1307 buttons = {
1310 text_func = function() return advanced and _("Edit identity URLs") or _("Identity") end,
1311 id = "ident",
1312 callback = function()
1313 close_nav_keyboard()
1314 Identities:confAt(current_nav_url(), function()
1315 update_buttons()
1316 end)
1317 end,
1318 hold_callback = function()
1319 close_nav_keyboard()
1320 Identities:edit()
1321 end,
1324 text_func = function() return advanced and _("View as text") or _("Page info") end,
1325 id = "info",
1326 callback = function()
1327 UIManager:close(nav)
1328 self:showCurrentInfo()
1329 end,
1330 hold_callback = function()
1331 UIManager:close(nav)
1332 self:viewCurrentAsText()
1333 end,
1338 text_func = function() return advanced and _("History") or _("Back") end,
1339 enabled = #self.history > 1,
1340 callback = function()
1341 UIManager:close(nav)
1342 self:goBack()
1343 end,
1344 hold_callback = show_hist,
1347 text_func = function() return advanced and _("History") or _("Unback") end,
1348 enabled = #self.unhistory > 0,
1349 callback = function()
1350 UIManager:close(nav)
1351 self:goBack(-1)
1352 end,
1353 hold_callback = show_hist,
1356 text_func = function() return advanced and _("Edit queue") or _("Next") end,
1357 enabled = #self.queue > 0,
1358 callback = function()
1359 UIManager:close(nav)
1360 self:openQueueItem()
1361 end,
1362 hold_callback = function()
1363 UIManager:close(nav)
1364 self:editQueue()
1365 end,
1368 text_func = function() return advanced and _("Edit marks") or _("Bookmarks") end,
1369 callback = function()
1370 UIManager:close(nav)
1371 self:openUrl("about:bookmarks")
1372 end,
1373 hold_callback = function()
1374 if self.ui.texteditor and self.ui.texteditor.quickEditFile then
1375 UIManager:close(nav)
1376 self:writeDefaultBookmarks()
1377 local function done_cb()
1378 if self:purl() and url.build(self:purl()) == "about:bookmarks" then
1379 self:openUrl("about:bookmarks", { replace_history = true })
1382 self.ui.texteditor:quickEditFile(marks_path, done_cb, true)
1383 else
1384 UIManager:show(InfoMessage:new{text = T(_([[
1385 Can't load TextEditor: Plugin disabled or incompatible.
1386 To edit bookmarks, please edit the file %1 in the koreader directory manually.
1387 ]]), marks_path)})
1389 end,
1394 text_func = function() return advanced and _("Root") or _("Up") end,
1395 callback = function()
1396 nav:setInputText(gemini.upUrl(current_nav_url()))
1397 update_buttons()
1398 end,
1399 hold_callback = function()
1400 nav:setInputText(url.absolute(current_nav_url(), "/"))
1401 update_buttons()
1402 end,
1405 text = _("Save"),
1406 id = "save",
1407 callback = function()
1408 local u = current_nav_url()
1409 local purl = url.parse(u)
1410 if purl and purl.scheme and purl.scheme == "about" then
1411 UIManager:show(InfoMessage:new{text = _("Can't save about: pages")})
1412 elseif u == gemini.showUrl(self:purl()) then
1413 UIManager:close(nav)
1414 self:saveCurrent()
1415 else
1416 UIManager:close(nav)
1417 self:openUrl(u, { body_cb = function(f, mimetype, p2)
1418 self:saveBody(f, mimetype, p2)
1419 end })
1421 end,
1424 text_func = function() return advanced and _("Prepend") or _("Add") end,
1425 callback = queue_nav_url,
1426 hold_callback = function() queue_nav_url(true) end,
1429 text_func = function() return advanced and _("Quick mark") or _("Mark") end,
1430 callback = function()
1431 if current_input_nonempty() then
1432 self:addMarkInteractive(current_nav_url())
1433 UIManager:close(nav)
1435 end,
1436 hold_callback = function()
1437 if current_input_nonempty()
1438 and self:addMark(current_nav_url()) then
1439 UIManager:close(nav)
1441 end,
1446 text = _("Cancel"),
1447 id = "close",
1448 callback = function()
1449 UIManager:close(nav)
1450 end,
1453 text_func = function() return advanced and _("Input") or _("Go") end,
1454 is_enter_default = true,
1455 callback = function()
1456 UIManager:close(nav)
1457 local u = current_nav_url()
1458 self:openUrl(u, { after_err_cb = function() self:showNav(u, true) end})
1459 end,
1460 hold_callback = function()
1461 local purl = url.parse(current_nav_url())
1462 if purl then
1463 self:userPromptInput(purl)
1464 UIManager:close(nav)
1466 end,
1471 update_buttons()
1472 -- FIXME: less hacky way to do this?
1473 nav._input_widget.edit_callback = function(edited)
1474 if edited then
1475 update_buttons()
1479 nav.title_bar.right_icon = "appbar.settings"
1480 nav.title_bar.right_icon_tap_callback = toggle_advanced
1481 nav.title_bar:init()
1483 UIManager:show(nav)
1484 if showKbd then
1485 nav:onShowKeyboard()
1489 function Client:onBrowseGemini()
1490 if self.active then
1491 self:showNav()
1492 elseif #self.history > 0 then
1493 self:openCurrent()
1494 elseif G_reader_settings:nilOrFalse("gemini_initiated") then
1495 self:openUrl("about:welcome")
1496 else
1497 self:openUrl("about:bookmarks")
1499 return true
1502 function Client:onGeminiBack()
1503 if self.active then
1504 self:goBack()
1505 return true
1508 function Client:onGeminiUnback()
1509 if self.active then
1510 self:goBack(-1)
1511 return true
1514 function Client:onGeminiHistory()
1515 if self.active then
1516 self:showHistoryMenu()
1517 return true
1520 function Client:onGeminiBookmarks()
1521 self:openUrl("about:bookmarks")
1522 return true
1524 function Client:onGeminiMark()
1525 if self.active then
1526 self:addMarkInteractive(gemini.showUrl(self:purl()))
1527 return true
1530 function Client:onGeminiNext()
1531 self:openQueueItem()
1532 return true
1534 function Client:onGeminiAdd()
1535 if self.active then
1536 self:queueCachedHistoryItem(self.history[1])
1537 return true
1540 function Client:onGeminiInput()
1541 if self.active then
1542 self:userPromptInput(self.history[1].purl)
1543 return true
1546 function Client:onGeminiReload()
1547 if self.active then
1548 self:openUrl(self.history[1].purl, { replace_history = true })
1549 return true
1552 function Client:onGeminiUp()
1553 if self.active then
1554 local u = gemini.showUrl(self:purl())
1555 local up = gemini.upUrl(u)
1556 if up ~= u then
1557 self:openUrl(up)
1559 return true
1562 function Client:onGeminiGoNew()
1563 self:showNav("")
1564 return true
1566 function Client:onGeminiNav()
1567 self:showNav()
1568 return true
1571 function Client:addToMainMenu(menu_items)
1572 menu_items.gemini = {
1573 sorting_hint = "search",
1574 text = _("Browse Gemini"),
1575 callback = function()
1576 self:onBrowseGemini()
1577 end,
1579 local hint = "search_settings"
1580 if Version:getNormalizedCurrentVersion() < 202305180000 then
1581 -- backwards compatibility
1582 hint = "search"
1584 menu_items.gemini_settings = {
1585 text = _("Gemini settings"),
1586 sorting_hint = hint,
1587 sub_item_table = {
1589 text = _("Show help"),
1590 callback = function()
1591 self:openUrl("about:help")
1592 end,
1595 text = T(_("Max cached history items: %1"), max_cache_history_items),
1596 help_text = _("History items up to this limit will be stored on the filesystem and can be accessed offline with Back."),
1597 keep_menu_open = true,
1598 callback = function(touchmenu_instance)
1599 local widget = SpinWidget:new{
1600 title_text = _("Max cached history items"),
1601 value = max_cache_history_items,
1602 value_min = 0,
1603 value_max = 200,
1604 default_value = default_max_cache_history_items,
1605 callback = function(spin)
1606 max_cache_history_items = spin.value
1607 G_reader_settings:saveSetting("gemini_max_cache_history_items", spin.value)
1608 touchmenu_instance:updateItems()
1609 end,
1611 UIManager:show(widget)
1612 end,
1615 text = _("Set directory for saved documents"),
1616 keep_menu_open = true,
1617 callback = function()
1618 local title_header = _("Current directory for saved gemini documents:")
1619 local current_path = save_dir
1620 local default_path = getDefaultSavesDir()
1621 local function caller_callback(path)
1622 save_dir = path
1623 G_reader_settings:saveSetting("gemini_save_dir", path)
1624 if not util.pathExists(path) then
1625 lfs.mkdir(path)
1628 filemanagerutil.showChooseDialog(title_header, caller_callback, current_path, default_path)
1629 end,
1632 text = _("Configure scheme proxies"),
1633 help_text = _("Configure proxy servers to use for non-gemini URL schemes."),
1634 callback = function()
1635 SchemeProxies:edit()
1636 end,
1639 text = _("Disable fetch on add"),
1640 help_text = _("Disables immediately fetching URLs added to the queue when connected."),
1641 checked_func = function()
1642 return G_reader_settings:isTrue("gemini_no_fetch_on_add")
1643 end,
1644 callback = function()
1645 G_reader_settings:flipNilOrFalse("gemini_no_fetch_on_add")
1646 end,
1649 text = _("Confirm certificates for new hosts"),
1650 help_text = _("Overrides the default behaviour of silently trusting the first server identity seen for a host, allowing you to confirm the certificate hash out-of-band."),
1651 checked_func = function()
1652 return G_reader_settings:isTrue("gemini_confirm_tofu")
1653 end,
1654 callback = function()
1655 G_reader_settings:flipNilOrFalse("gemini_confirm_tofu")
1656 end,
1662 return Client