init commit
This commit is contained in:
@@ -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)
|
||||
+118
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user