commit 8b4c8c7e8ed9f2090778c03e61a975084cf36962
Author: Luxferre <lux@ferre>
Date: Sat, 25 Mar 2023 16:51:49 +0200
init commit
Diffstat:
12 files changed, 690 insertions(+), 0 deletions(-)
diff --git a/COPYING b/COPYING
@@ -0,0 +1,26 @@
+This is free and unencumbered software released into the public domain.
+
+Anyone is free to copy, modify, publish, use, compile, sell, or
+distribute this software, either in source code form or as a compiled
+binary, for any purpose, commercial or non-commercial, and by any
+means.
+
+In jurisdictions that recognize copyright laws, the author or authors
+of this software dedicate any and all copyright interest in the
+software to the public domain. We make this dedication for the benefit
+of the public at large and to the detriment of our heirs and
+successors. We intend this dedication to be an overt act of
+relinquishment in perpetuity of all present and future rights to this
+software under copyright law.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+For more information, please refer to <https://unlicense.org>
+
+
diff --git a/README.md b/README.md
@@ -0,0 +1,86 @@
+# Kopher: a simple Gopher client/browser for KaiOS
+
+> Yo dawg, we put a browser in your browser, so you can browse Gopher while you browse KaiOS UI
+
+## What is it?
+
+Kopher is a really small (under 500 SLOC of HTML+JS) client to browse pages ("holes") using Gopher protocol. Besides Gophermaps, it also allows to display plain text downloaded via this protocol "as is" and redirect to the system browser to view non-Gopher protocol links and download non-text media file types.
+
+Structurally, Kopher is a KaiOS-specific frontend to the hi01379.js library which is developed under the same project. Both the library and the app are designed to be as lightweight as practically possible.
+
+## Which KaiOS versions are supported?
+
+Tested on 2.5 up to 2.5.4. May work on 1.0 (e.g. Alcatel Cingular Flip 2), definitely won't work on 3.x.
+
+## Which Gophermap entry types are supported?
+
+They are listed in the hi01379.js library name:
+
+- `0` (plain text)
+- `1` (Gophermap)
+- `3` (error message)
+- `7` (search request)
+- `9` (arbitrary binary type), as well as `g` (GIF image file)
+- `i` (information message), as well as `I` (generic image file)
+- `h` (external URL)
+
+Outside this list, unknown binary types (`5`, `s`, `;`, `d`) are just treated as type `9` internally, and every other type is assumed to be plain text on access.
+
+In the Gophermap, any line that doesn't contain tabulation characters is automatically considered an information line by Kopher client and hi01379.js library. This allows to display messages from Gopher servers that don't strictly adhere to the format, such as Gophernicus or PyGopherd.
+
+Note that currently, Kopher and hi01379.js assume that the search request from a 7-type entry will lead to another Gophermap and not to any other resource type. This was necessary to remove ambiguity, as the client doesn't heuristically determine the response type from its body.
+
+## Which UI features are supported?
+
+- Minimalistic but easy and quick WAP-like navigation (see "Controls")
+- The gopher:// scheme is assumed by default (no need to enter it manually), same for the port 70
+- One-key light/dark theme toggle
+- One-key switching the line wrapping mode between off (default) and on (for comfortable reading)
+- Unlimited navigation history (doesn't persist between sessions)
+- Up to 10 numbered bookmarks plus customizable homepage
+
+## Controls
+
+Kopher's controls were inspired by old WAP browsers and partially Opera Mini. Once you get used to them, navigation becomes blazingly fast.
+
+- Scrolling: `Up`/`Down`/`Left`/`Right`
+- Previous/next link: `2`/`5`
+- Click on the link: `Center`
+- Enter address: Left Soft Key
+- Go back: `1` or Right Soft Key
+- Go forward: `3`
+- Refresh: `Call`
+- Page start/end: `8`/`0`
+- Line start/end: `7`/`9`
+- Open the homepage: `# *`
+- Open a bookmark: `# [num]`
+- Save to the homepage: `* *`
+- Save to a bookmark: `* [num]`
+- Toggle light/dark theme: `4`
+- Toggle line wrapping: `6`
+- Version and page info: `# #`
+- Exit: End
+
+## Wait... no input fields in the UI?!
+
+Exactly. No need to reinvent the wheel when the JS engine offers the `prompt()` method. You enter the starting address in the popup window, same way you enter search queries in the gopherholes.
+
+For your convenience, only the current hole hostname is always displayed in the header bar, and the status bar with service messages or the current resource path is always displayed below. You can view the full URL/URI in the "Version and page info" popup accessed with `# #` combo.
+
+## Does it have Unicode support?
+
+Yes. In fact, UTF-8 is the only supported encoding. As ASCII is a subset of UTF-8, old ASCII-only resources will be displayed just fine as well.
+
+## Does it have terminal emulation support for character coloring purposes?
+
+No, but that feature is considered to be implemented if this client gains more traction and if there still is enough amount of the resources that actually use VT escape characters.
+
+## Can I use the hi01379.js library in non-KaiOS environments?
+
+Yes, but you'll need to supply your own `gopherRequest(resource, input, successCb, errorCb)` function/method that performs the actual resource download over TCP. The `success` callback must always return a raw binary data string. See the js/transport.js file for the reference of how it's done for KaiOS (using mozTCPSocket API).
+
+If you only need the Gophermap rendering functionality, you can provide a dummy function and only use the `Hi01379.render()` method. Note that the library generates links in its own internal and more efficient `hi:[type]|[selector]|[host]|[port]` URI format, where the fields are specified in the same order they appear in the Gophermap. It's up to the callers to convert these links to the standard `gopher://[host]:[port]/[type][selector]` URL scheme if they need to.
+
+## What's the license on this?
+
+Fully public domain.
diff --git a/app.css b/app.css
@@ -0,0 +1,65 @@
+:root {
+ --bg-color: #fff;
+ --fg-color: #111;
+ --link-color: darkblue;
+}
+
+* {box-sizing: border-box; margin:0;padding:0}
+html, body {
+ position: fixed;
+ width: 100%;
+ height: 100%;
+ color: var(--fg-color);
+ background: var(--bg-color);
+ font-family: sans-serif
+}
+body[data-theme="dark"] {
+ --bg-color: #111;
+ --fg-color: #fff;
+ --link-color: cyan;
+}
+
+header {position: absolute; top: 0; left: 0; padding-left: 1px; width: 100%; height: 26px; border-bottom: 1px solid var(--fg-color); vertical-align: middle}
+main {position: absolute; top: 26px; left: 0; width: 100%; height: calc(100% - 46px);font-size: 16px}
+footer {position: absolute; bottom: 0; left: 0; width: 100%; padding: 0 4px; height: 20px; border-top: 1px solid var(--fg-color); font-size: 14px}
+
+header>* {display:inline-block; font-size: 18px; padding: 2px; vertical-align: middle;line-height:22px}
+header .addr {text-overflow: ellipsis; overflow: hidden; max-width: 87%; white-space: nowrap}
+footer .status {width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap}
+
+.content {
+ position: absolute;
+ left: 2%;
+ width: 96%;
+ height: 100%;
+ overflow: scroll;
+ line-height:1.5em;
+ white-space: pre;
+}
+
+.content[data-wrap="1"] {
+ white-space: pre-wrap;
+}
+
+.content[data-format="plain"] {
+ font-family: "Droid Sans Mono", monospace;
+ font-size: 15px;
+}
+
+.content pre {
+ font-family: "Droid Sans Mono", monospace;
+ font-size: 15px;
+ white-space: inherit!important;
+}
+
+.content a:before {
+ content: "\21d2 ";
+ color: var(--link-color);
+ text-decoration: none;
+}
+.content a, .content a:visited, .content a:active {
+ color: var(--link-color);
+ text-decoration: none;
+}
+.content a[data-focused="1"] {text-decoration: underline}
+
diff --git a/img/icon112.png b/img/icon112.png
Binary files differ.
diff --git a/img/icon56.png b/img/icon56.png
Binary files differ.
diff --git a/index.html b/index.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset=utf-8>
+ <meta name=viewport content="width=device-width">
+ <title>Kopher</title>
+ <link rel=stylesheet href="app.css">
+ </head>
+ <body>
+ <header><div class=logo>🌐</div><div class=addr></div></header>
+ <main><div class=content data-wrap=0></div></main>
+ <footer><div class=status>READY</div></footer>
+ <script src="js/transport.js"></script>
+ <script src="js/hi01379.js"></script>
+ <script src="js/app.js"></script>
+ </body>
+</html>
diff --git a/js/app.js b/js/app.js
@@ -0,0 +1,299 @@
+// Kopher UI logic
+
+function openURL(url, successCb, errorCb) {
+ if(url.startsWith('about:')) { // handle about: pages first
+ var pageName = url.split(':')[1], allowedPageNames = ['kopher', 'blank', 'help']
+ if(allowedPageNames.indexOf(pageName) > -1) {
+ if(pageName === 'blank')
+ successCb({content: '', serviceMsg: ''})
+ else {
+ var req = new XMLHttpRequest()
+ req.addEventListener('load', function() {
+ successCb({content: Hi01379.render(req.responseText), serviceMsg: ''})
+ })
+ req.open('GET', '/pages/' + pageName + '.txt')
+ req.send()
+ }
+ }
+ return
+ } // then handle everything else
+ if(url.startsWith('gopher://')) { // convert the external URL format to the internal one
+ var parts = new URL(url.replace('gopher://', 'http://'))
+ var type = parts.pathname[2] || '1', sel = parts.pathname.substr(2)
+ url = 'hi:' + [type, sel, parts.hostname, (parts.port|0) || 70].join('|')
+ }
+ if(url.startsWith('hi:')) { // Internal Gopher URL format
+ var resource = url.split(':').slice(1).join(':').split('|'), input = null
+ if(resource.length > 4) input = resource[4]
+ Hi01379.load(resource, input, function(res) {
+ if(res.content && res.content instanceof Blob) { // handle the download here
+ var opener = new MozActivity({name: 'open', data: {type: res.contentType, blob: res.content, filename: res.contentName}})
+ opener.onsuccess = function() {
+ successCb({content: null, serviceMsg: 'Opening a downloaded file ' + contentName, updateAddr: false})
+ }
+ opener.onerror = errorCb
+ }
+ else successCb(res) // proceed to UI with non-downloads
+ }, errorCb)
+ }
+ else { // handle non-Gopher URLs with the Web Activities
+ var opener = new MozActivity({name: 'view', data: {type: 'url', url: url}})
+ opener.onsuccess = function() {
+ successCb({content: null, serviceMsg: 'Opening an external resource ' + url, updateAddr: false})
+ }
+ opener.onerror = errorCb
+ }
+}
+
+// App entry point
+addEventListener('DOMContentLoaded', function() {
+ var keycmd = '', currentUrl = '',
+ addrBar = document.querySelector('header div.addr'),
+ logoStatus = document.querySelector('div.logo'),
+ logoDefaultChar = '\u{1f310}',
+ logoLoadingChar = '\u23f3',
+ contentZone = document.querySelector('main div.content'),
+ statusBar = document.querySelector('footer div.status'),
+ themeKey = 'uitheme', bookmarkKey = 'bookmarks', bkNumber,
+ history = [], historyIndex = 0, linkIndex = 0,
+ verticalScrollStep = 20, horizontalScrollStep = 20,
+ buf = '', emptyBk = '["","","","","","","","","","",""]'
+
+ function getTheme() {
+ return (localStorage.getItem(themeKey) == 'dark') ? 'dark' : 'light'
+ }
+
+ function updateTheme() {
+ document.body.dataset.theme = getTheme()
+ }
+
+ function toggleTheme() {
+ var theme = getTheme()
+ localStorage.setItem(themeKey, (theme == 'dark') ? 'light' : 'dark')
+ updateTheme()
+ }
+
+ function setBookmark(index, url) { // update a bookmark or a homepage (which is just bookmark #0)
+ var bmArray = JSON.parse(localStorage.getItem(bookmarkKey) || emptyBk)
+ bmArray[index] = url
+ localStorage.setItem(bookmarkKey, JSON.stringify(bmArray))
+ }
+
+ function getBookmark(index) { //retrieve a bookmark or a homepage
+ var bmArray = JSON.parse(localStorage.getItem(bookmarkKey) || emptyBk)
+ return bmArray[index]
+ }
+
+ function loadURL(url, noNavUpdate) { // wrapper to openURL that actually updates all the UI and history
+ logoStatus.textContent = logoLoadingChar
+ statusBar.textContent = 'Loading ' + url
+ openURL(url, function(res) { // success callback
+ logoStatus.textContent = logoDefaultChar
+ statusBar.textContent = ''
+ var updateHistory = !('updateAddr' in res && res.updateAddr === false), // true by default
+ updateContent = !(res.content === null) // true by default
+ if(updateContent) { // update the content zone
+ contentZone.innerHTML = ''
+ contentZone.textContent = ''
+ contentZone.dataset.wrap = 0 // reset line wrapping
+ if(res.contentType === 'text/plain') {
+ contentZone.dataset.format='plain'
+ contentZone.textContent = res.content
+ } else {
+ contentZone.dataset.format='native'
+ contentZone.innerHTML = res.content
+ var contentLinks = contentZone.querySelectorAll('a'), l = contentLinks.length, i
+ for(i=0;i<l;i++) { // dynamically index all the links in the rendered content
+ contentLinks[i].dataset.index = i
+ contentLinks[i].dataset.focused = (i === 0) ? 1 : 0 // automatically focus the first link
+ }
+ }
+ contentZone.scrollTop = 0 // reset vert. scrolling
+ contentZone.scrollLeft = 0 // reset horiz. scrolling
+ }
+ if(updateHistory && !noNavUpdate) { // first, trim the history to the current history index, then push the new URL
+ history = history.slice(0, historyIndex+1)
+ history.push(url)
+ historyIndex = history.length - 1
+ currentUrl = url
+ }
+ if(url.startsWith('about:'))
+ addrBar.textContent = url
+ else
+ addrBar.textContent = url.startsWith('hi:') ? url.split(':')[1].split('|')[2] : url.split('/')[2] // display the hostname
+ statusBar.textContent = res.serviceMsg // update statusbar
+ }, function(errObj) { // error callback
+ logoStatus.textContent = logoDefaultChar
+ statusBar.textContent = 'Error: ' + errObj.message
+ alert('Error: ' + errObj.message)
+ })
+ }
+
+ function focusLink(linkDelta) {
+ var contentLinks = contentZone.querySelectorAll('a'), l = contentLinks.length
+ if(l) { // ignore the logic if there are no links
+ var curLink = contentZone.querySelector('a[data-focused="1"]'),
+ curIndex = curLink.dataset.index|0,
+ nextIndex = curIndex + linkDelta
+ if(nextIndex < 0) nextIndex = l - 1
+ else if(nextIndex >= l) nextIndex = 0
+ var nextLink = contentLinks[nextIndex]
+ curLink.dataset.focused = 0
+ nextLink.dataset.focused = 1
+ // try to center the link
+ contentZone.scrollTop = nextLink.offsetTop - (contentZone.offsetHeight >> 1) + (nextLink.offsetHeight >> 1)
+ }
+ }
+
+ function clickSelectedLink() {
+ var curLink = contentZone.querySelector('a[data-focused="1"]')
+ if(curLink) {
+ var input = null, href = decodeURI(curLink.href)
+ if(href.startsWith('hi:7')) { // internal format
+ href = href.split(':')[1].split('|') // type|sel|host|port
+ input = encodeURIComponent(prompt('Input data for ' + href[2]))
+ href.push(input)
+ href = 'hi:'+href.join('|')
+ }
+ loadURL(href)
+ }
+ }
+
+ function execKeyCmd(cmd) { //cmd can be a digit, digit combo, or key symbolic name
+ switch(cmd) {
+ case 'ArrowUp': // scroll up
+ contentZone.scrollTop -= verticalScrollStep
+ break
+ case 'ArrowDown': // scroll down
+ contentZone.scrollTop += verticalScrollStep
+ break
+ case 'ArrowLeft': // scroll left
+ contentZone.scrollLeft -= horizontalScrollStep
+ break
+ case 'ArrowRight': // scroll right
+ contentZone.scrollLeft += horizontalScrollStep
+ break
+ case 'SoftLeft': // go to the address (no relative URLs in this case)
+ buf = prompt('Enter address').trim()
+ if(buf) { // only proceed if the address was entered
+ if(buf.indexOf('://') == -1) buf = 'gopher://' + buf
+ loadURL(buf)
+ }
+ break
+ case 'Enter': // click on the current link
+ clickSelectedLink()
+ break
+ case '4': // toggle the UI theme
+ toggleTheme()
+ break
+ case '6': // toggle line wrapping
+ contentZone.dataset.wrap = 1 - parseInt(contentZone.dataset.wrap)
+ break
+ case 'Call': // refresh
+ loadURL(currentUrl, true)
+ break
+ case '1': // back
+ case 'SoftRight':
+ historyIndex--
+ if(historyIndex < 0) //we're already at the beginning, do nothing
+ historyIndex = 0
+ else
+ loadURL(history[historyIndex], true)
+ break
+ case '3': // forward
+ historyIndex++
+ if(historyIndex >= history.length) //we're already at the end, do nothing
+ historyIndex = history.length - 1
+ else
+ loadURL(history[historyIndex], true)
+ break
+ case '2': // jump to previous link
+ focusLink(-1)
+ break
+ case '5': // jump to next link
+ focusLink(1)
+ break
+ case '7': // hor.scroll to the start
+ contentZone.scrollLeft = 0
+ break
+ case '8': // vert.scroll to the start
+ contentZone.scrollTop = 0
+ break
+ case '9': // hor.scroll to the end
+ contentZone.scrollLeft = contentZone.scrollLeftMax // FF only
+ break
+ case '0': // vert.scroll to the end
+ contentZone.scrollTop = contentZone.scrollTopMax // FF only
+ break
+ case '*1': // save bookmarks
+ case '*2':
+ case '*3':
+ case '*4':
+ case '*5':
+ case '*6':
+ case '*7':
+ case '*8':
+ case '*9':
+ case '*0':
+ bkNumber = cmd[1]|0
+ if(!bkNumber) bkNumber = 10
+ if(currentUrl && confirm('Save current URL to the bookmark #'+bkNumber+'?'))
+ setBookmark(bkNumber, currentUrl)
+ break
+ case '**': // save homepage
+ if(currentUrl && confirm('Save current URL as the homepage?'))
+ setBookmark(0, currentUrl)
+ break
+ case '#1': // load bookmark
+ case '#2':
+ case '#3':
+ case '#4':
+ case '#5':
+ case '#6':
+ case '#7':
+ case '#8':
+ case '#9':
+ case '#0':
+ bkNumber = cmd[1]|0
+ if(!bkNumber) bkNumber = 10
+ buf = getBookmark(bkNumber)
+ if(buf) loadURL(buf)
+ else alert('No bookmark available at #'+bkNumber)
+ break
+ case '#*': // load homepage
+ buf = getBookmark(0)
+ if(buf) loadURL(buf)
+ else loadURL('about:kopher')
+ break
+ case '##': // version info
+ navigator.mozApps.getSelf().then(function(app) {
+ var mfst = app.manifest
+ var aboutText = mfst.name + ' v' + mfst.version + ' by ' + mfst.developer.name
+ aboutText += '\nPage: ' + currentUrl
+ alert(aboutText)
+ })
+ break
+ }
+ }
+
+ // setup the input
+ addEventListener('keydown', function(e) {
+ var k = e.key
+ if(keycmd.length > 0) { // command is already buffering
+ execKeyCmd(keycmd+k)
+ keycmd = ''
+ }
+ else if(k === '*' || k === '#') // start buffering
+ keycmd = k
+ else // single-key command
+ execKeyCmd(k)
+ }, false)
+
+ //setup the theme
+ updateTheme()
+
+ // load the homepage if present
+ var hp = getBookmark(0)
+ loadURL(hp || 'about:kopher')
+
+}, false)
diff --git a/js/hi01379.js b/js/hi01379.js
@@ -0,0 +1,118 @@
+// Platform-agnostic Gopher response handler and Gophermap parser
+// Depends on the platform-specific gopherRequest(resource, input, success, error) function
+// (defined in the transport.js file here)
+
+Hi01379 = (function(psGopherRequest) {
+
+ var tagsToReplace = {'&': '&', '<': '<', '>': '>'}
+
+ function esc(str) { // escape HTML entities
+ return str.replace(/[&<>]/g, function(tag) {return tagsToReplace[tag] || tag})
+ }
+
+ function gophermapToHTML(gmap, chost, cport) {
+ if(!cport) cport = 70
+ if(!chost) chost = 'localhost'
+ var lines = gmap.split(/\r?\n/), line, l = lines.length, i, lbr = '<br>',
+ type, rest, desc, sel, host, port,
+ output = '', pretag = 'pre', preflag = false
+ for(i=0;i<l;i++) {
+ line = lines[i]
+ if(line === '.')
+ break // immediately stop parsing on the . line
+ if(line.indexOf('\t') == -1 && !line.startsWith('i')) // this also handles empty lines
+ line = 'i' + line
+ type = line[0] // type
+ rest = line.substr(1).split('\t')
+ desc = esc(rest[0]) // escaping description (obviously must not contain tabs)
+ sel = rest.length > 1 ? rest[1] : '' // selector
+ host = rest.length > 2 ? rest[2] : '' // hostname
+ port = rest.length > 3 ? (0|rest[3]) : '' // port
+ // check for the empty fields
+ if(!sel) sel = desc // selector defaults to the description
+ if(!host) host = chost // host defaults to the current host
+ if(!port) port = cport // port defaults to the current port
+ if(type == 'i') { // information message - wrap them in pretag
+ if(!preflag) {
+ preflag = true
+ output += '<' + pretag + '>'
+ }
+ output += desc + '\n'
+ }
+ else { //information messages ended, stop preformatting
+ if(preflag) {
+ preflag = false
+ output += '</' + pretag + '>'
+ }
+ if(desc = desc.trim()) { // ignore empty descriptions
+ if(type == '3') // error message
+ output += '<span class=error>' + desc + '</span>' + lbr
+ else { // shape the link (internal hi:type|sel|host|port format unless it's an external URL)
+ var deflink = 'hi:'+[type,sel,host,port].join('|'), link = deflink
+ if(type == 'h' && sel.startsWith('URL:')) {
+ link = sel.split(':').slice(1).join(':').trim()
+ if(link.startsWith('javascript:')) // XSS protection
+ link = deflink
+ }
+ output += '<a href="' + encodeURI(link) + '">' + desc + '</a>' + lbr
+ }
+ }
+ }
+ }
+ return output
+ }
+
+ // resource format: [type, selector, host, port]
+ function loadHole(resource, input, successCb, errorCb) {
+ psGopherRequest(resource, input, function(rawdata, type) {
+ if(type == '5' || type == 's' || type == ';' || type == 'd')
+ type = '9' // remap unknown binary types to 9
+ if(type == '9' || type == 'g' || type == 'I') { // binary file
+ var ctype = 'application/octet-stream' // the default one if everything else fails
+ var datablob = new Blob([rawdata], {type: ctype}),
+ fname = resource[1].split('/').pop() // as the most intelligent guess
+ // update content type
+ if(type == 'g') // GIF-only resource type
+ ctype = 'image/gif'
+ else if(type == 'I') { // attempt to guess the image MIME type by the extension
+ var ext = fname.split('.').pop().toLowerCase(),
+ extMappings = { // not including AVIF here because they aren't supported by Gecko 48 anyway
+ 'gif': 'image/gif', // including GIF though because nothing prevents using I instead of g
+ 'png': 'image/png',
+ 'svg': 'image/svg+xml',
+ 'webp': 'image/webp',
+ 'apng': 'image/apng',
+ 'jpg': 'image/jpeg',
+ 'jpeg': 'image/jpeg',
+ 'jfif': 'image/jpeg',
+ 'pjpeg': 'image/jpeg',
+ 'pjp': 'image/jpeg'
+ }
+ if(ext in extMappings)
+ ctype = extMappings(ext)
+ }
+ successCb({
+ content: datablob,
+ contentType: ctype,
+ contentName: fname,
+ serviceMsg: type + ' ' + resource[1],
+ updateAddr: false
+ })
+ }
+ else { // assuming text content otherwise
+ var output = decodeURIComponent(escape(rawdata)), ctype = 'text/plain' // defaulting to type 0
+ if(type == '1' || type == '7') { // gophermap
+ output = gophermapToHTML(output, resource[2], resource[3])
+ ctype = 'text/html'
+ }
+ successCb({
+ content: output,
+ contentType: ctype,
+ serviceMsg: type + ' ' + resource[1]
+ })
+ }
+ }, errorCb)
+ }
+
+ return {load: loadHole, render: gophermapToHTML}
+})(gopherRequest)
diff --git a/js/transport.js b/js/transport.js
@@ -0,0 +1,24 @@
+// KaiOS 2.x specific part to connect and retrieve raw data from Gopher servers
+// Requires "tcp-socket" permission in the manifest
+// and the "privileged" or "certified" app permission level
+// resource format: [type, selector, host, port]
+// type is autotrimmed, so you don't have to manually strip the description
+// input is optional and only considered for resource type 7
+
+function gopherRequest(resource, input, successCb, errorCb) {
+ var xsock = navigator.mozTCPSocket.open(resource[2], (resource[3]||70) | 0, {binaryType: 'string'}),
+ type = resource[0][0], databuf = ''
+ xsock.ondata = function(data) {
+ databuf += data.data
+ }
+ xsock.onclose = function() {
+ successCb(databuf, type)
+ }
+ xsock.onerror = errorCb
+ xsock.onopen = function() {
+ var selpath = resource[1]
+ if(type == '7' && input)
+ selpath += '\t' + input
+ xsock.send(selpath + '\r\n') // send CRLF-terminated selector path and optionally the search string
+ }
+}
diff --git a/manifest.webapp b/manifest.webapp
@@ -0,0 +1,26 @@
+{
+ "version": "0.0.1",
+ "name": "Kopher",
+ "description": "A Gopher client for KaiOS",
+ "launch_path": "/index.html",
+ "icons": {
+ "112": "/img/icon112.png",
+ "56": "/img/icon56.png"
+ },
+ "developer": {
+ "name": "Luxferre",
+ "url": "https://luxferre.top"
+ },
+ "type": "privileged",
+ "origin": "app://kopher.luxferre.top",
+ "permissions": {
+ "tcp-socket": {
+ "description": "To fetch Gopher resources"
+ }
+ },
+ "installs_allowed_from": [
+ "*"
+ ],
+ "default_locale": "en"
+}
+
diff --git a/pages/help.txt b/pages/help.txt
@@ -0,0 +1,18 @@
+Kopher controls
+* Scrolling: Up/Down/Left/Right
+* Previous/next link: 2/5
+* Click on the link: Center
+* Enter address: Left Soft Key
+* Go back: 1 or Right Soft Key
+* Go forward: 3
+* Refresh: Call
+* Page start/end: 8/0
+* Line start/end: 7/9
+* Open the homepage: # *
+* Open a bookmark: # [num]
+* Save to the homepage: * *
+* Save to a bookmark: * [num]
+* Toggle light/dark theme: 4
+* Toggle line wrapping: 6
+* Version and page info: # #
+* Exit: End
diff --git a/pages/kopher.txt b/pages/kopher.txt
@@ -0,0 +1,11 @@
+Welcome to Kopher
+hControls help URL:about:help
+Useful starting links:
+7Veronica-2 search /v2/vs gopher.floodgap.com 70
+1Gopherpedia / gopherpedia.com 70
+1Floodgap portal / gopher.floodgap.com 70
+1SDF portal / sdf.org 70
+
+
+Created by Luxferre in 2023
+Released into public domain