init commit

This commit is contained in:
Luxferre
2023-03-25 16:51:49 +02:00
commit 8b4c8c7e8e
12 changed files with 690 additions and 0 deletions
+26
View File
@@ -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>
+86
View File
@@ -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.
+65
View File
@@ -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}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+17
View File
@@ -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>&#127760;</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>
+299
View File
@@ -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
View File
@@ -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 = {'&': '&amp;', '<': '&lt;', '>': '&gt;'}
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)
+24
View File
@@ -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
}
}
+26
View File
@@ -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"
}
+18
View File
@@ -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
+11
View File
@@ -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