UNPKG

ebrowser

Version:

The keyboard-friendly minimal suckless web browser

856 lines (846 loc) 24 kB
<!-- Copyright (C) 2024 Richard Hao Cao Ebrowser is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Ebrowser is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. --> <!DOCTYPE html><html><head><meta charset="UTF-8"> <style> html{ height: 100%; overflow: hidden; } body{ display: flex; flex-direction: column; height: 100%; margin-top: 1px; } div.webviews{ display: flex; flex-direction: column; flex-grow:1; } webview{display: none;width:100%;height:100%} .curWV{display: inherit !important;} .autocomplete-active { background-color: DodgerBlue !important; color: #ffffff; } .invis{display: none} /*the container must be positioned relative:*/ .autocomplete { position: relative; display: inline-block; width:100%; } .autocomplete-items { position: absolute; border: 1px solid #d4d4d4; border-bottom: none; border-top: none; z-index: 99; /*position the autocomplete items to be the same width as the container:*/ top: 100%; left: 0; right: 0; } .autocomplete-items div { cursor: pointer; background-color: #fff; } .autocomplete-items div:hover { background-color: #e9e9e9; } </style> <script> const { ipcRenderer } = require('electron'); const fs = require('fs'); const path = require('path'); const readline = require('readline'); const cssInvert = "html,img,video{filter:invert(1) hue-rotate(180deg) !important;}body{background:white !important;}img[src],input[type=image]{opacity:.9;}"; var iTab = 0; var tabs; var engines = {}; var mapKeys = {}; var closedUrls = []; var autocStrArray = []; var defaultSE = "https://www.bing.com/search?q=%s"; var historyFile = path.join(__dirname,'history.rec'); var bHistory = false; var bQueryHistory = false; const currentHour = new Date().getHours(); var bDarkTheme = (currentHour >= 17 || currentHour < 8); let sitecssP = path.join(__dirname,"sitecss"); let sitejsP = path.join(__dirname,"sitejs"); let gjsA = []; let gcssA = []; let gcssJSA = []; var bDomainJS = fs.existsSync(sitejsP); var bDomainCSS = fs.existsSync(sitecssP); var autocMode = 0; //0 for substring, 1 for startsWith const BML_head = "(async ()=>{let d=document;async function _loadJs(u){var a=d.createElement('script');a.type='text/javascript';a.async=false;a.src=u;d.body.appendChild(a);await new Promise(resolve=>a.onload=resolve)}"; const BML_tail = "})()"; const BML_md = BML_head + "await _loadJs('https://cdn.jsdelivr.net/npm/marked@15.0.6/marked.min.js');let b=d.body;b.innerHTML=marked.parse(b.textContent)})()"; let lastKeys; let lastKeys_millis = 0; var lastVal; fs.readFile(path.join(__dirname,'search.json'), 'utf8', (err, jsonString) => { if (err) { coloncommand(":js fetch2file(repositoryurl,'search.json')"); return; } initSearchEngines(jsonString,false); }); fs.readFile(path.join(__dirname,'mapkeys.json'), 'utf8', (err, jsonStr) => { if (err) { coloncommand(":js fetch2file(repositoryurl,'mapkeys.json')"); return; } try { mapKeys = JSON.parse(jsonStr); }catch(e){} }); appendAutoc_rec(path.join(__dirname,'default.autoc'),null); appendAutoc_rec(path.join(__dirname,'bookmark.rec'),' '); function initSearchEngines(jsonStr){ try{ engines=JSON.parse(jsonStr); let match = jsonStr.match(/:"([^"]+)"/); if(match) defaultSE=match[1]; }catch(e){} } function save(filePath, u8array){ //alert(Object.prototype.toString.call(u8array)) fs.writeFile (filePath, u8array, (err) => { if (err) { console.error (err); return; } }); } function print2PDF(filePath, options){ tabs.children[iTab].printToPDF(options) .then(u8array=>save(filePath,u8array)); } function showHtml(html){ let js = "document.documentElement.innerHTML=`"+html+"`;"; let t=tabs.children[iTab]; t.dataset.jsonce=js; t.src="about:blank"; } function bookmark(argrest){//b [filenamestem] :bookmark let bmFileName = "bookmark.rec"; let tab = tabs.children[iTab]; let url = tab.getURL(); if(argrest) bmFileName = argrest+".rec"; let title = tab.getTitle(); let line = title + " " + url + "\n"; fs.appendFile(path.join(__dirname,bmFileName), line, (err)=>{}); } function switchTab(i){ let tab = tabs.children[iTab]; if(document.activeElement == tab) tab.blur(); tab.classList.remove('curWV'); iTab = i; tabs.children[iTab].classList.add('curWV'); } async function loadJSFile(tab,jsF){ try { let js = await fs.promises.readFile(jsF,'utf8'); tab.executeJavaScript(js,false); }catch(e){} } async function loadCSSFile(tab,jsF){ try { let js = await fs.promises.readFile(jsF,'utf8'); tab.insertCSS(js); }catch(e){} } function cbStartLoading(e){ let tab = e.target; if(bDarkTheme) tab.insertCSS(cssInvert); if(!bDomainCSS) return; let domain; try{ domain = new URL(tab.getURL()).hostname; }catch(e){return;} let jsF = path.join(sitecssP, domain+".js"); loadJSFile(tab,jsF); jsF = path.join(sitecssP, domain+".css"); loadCSSFile(tab,jsF); gcssA.forEach((fname)=>{ jsF = path.join(__dirname, fname); loadCSSFile(tab,jsF); }); gcssJSA.forEach((fname)=>{ jsF = path.join(__dirname, fname); loadJSFile(tab,jsF); }); } function cbNavigate(e){ let url = e.url; if(58===url.charCodeAt(1)){ e.preventDefault(); if(confirm("Proceed to execute risky operations: "+url)) internalLink(url); else document.forms[0].q.value = url; } } function cbFinishLoad(e){ let tab = e.target; let url = tab.getURL(); if(bHistory){ let histItem = tab.getTitle()+" "+url+"\n"; fs.appendFile(historyFile, histItem, (err) => {}); } let js = tab.dataset.jsonce; if(js){ tab.dataset.jsonce = null; tab.executeJavaScript(js,false); } if(bDomainJS){ let domain = new URL(url).hostname; let jsF = path.join(sitejsP, domain+".js"); loadJSFile(tab,jsF); } gjsA.forEach((fname)=>{ let jsF = path.join(__dirname, fname); loadJSFile(tab,jsF); }); } function initTab(tab){ tab.allowpopups = true; tab.addEventListener('did-finish-load',cbFinishLoad); tab.addEventListener('dom-ready',cbStartLoading); tab.addEventListener('will-navigate',cbNavigate); } function newTab(){ var tab = document.createElement('webview'); initTab(tab); tabs.appendChild(tab); } function tabInc(num){ let nTabs = tabs.children.length; if(nTabs<2) return; let i = iTab +num; if(i>=nTabs) i=0; switchTab(i); } function tabDec(num){ let nTabs = tabs.children.length; if(nTabs<2) return; let i = iTab +num; if(i<0) i=nTabs-1; switchTab(i); } function tabClose(){ let nTabs = tabs.children.length; if(nTabs<2) return "";//no remain tab let tab = tabs.children[iTab]; try { closedUrls.push(tab.getURL()); } catch(e){} if(document.activeElement == tab) tab.blur(); tabs.removeChild(tab); nTabs--; if(iTab>=nTabs) iTab=iTab-1; tabs.children[iTab].classList.add('curWV'); return getWinTitle(); } function tabJS(js){ tabs.children[iTab].executeJavaScript(js,false); } function getWinTitle(){ let t=tabs.children[iTab]; let title = (iTab+1) + '/' + tabs.children.length; try{title=title+' '+t.getTitle()+' '+t.getURL()}catch(e){} return title } async function appendAutoc_rec(filename, delimit){ try{ const readInterface = readline.createInterface ({ input: fs.createReadStream (filename, 'utf8'), }); for await (const line of readInterface) { let iS; if(delimit && (iS=line.lastIndexOf(delimit))>0){ autocStrArray.push(line.substring(iS+1)); }else autocStrArray.push(line); } lastVal = null; //trigger full search }catch(e){return;} } function keyPress(e){ var inputE = document.forms[0].q; if (e.altKey||e.metaKey) return; var key = e.key; if(e.ctrlKey){ switch(key){ case "Home": tabJS("window.scrollTo(0,0)"); return; case "End": tabJS("window.scrollTo(0,document.body.scrollHeight)"); return; } return; } SCROLL: do { let h = -32; switch(key){ case " ": if(inputE === document.activeElement) return; if(e.shiftKey){ h = -3*document.documentElement.clientHeight/4; break; } case "PageDown": h = 3*document.documentElement.clientHeight/4; break; case "PageUp": h = -3*document.documentElement.clientHeight/4; break; case "ArrowDown": h = 32; case "ArrowUp": if(inputE === document.activeElement && 0!==inputE.nextElementSibling.children.length) return; break; default: break SCROLL; } let js = `javascript:window.scrollBy(0,${h})`; tabJS(js); return; }while(false); if(inputE === document.activeElement){ if (9===e.keyCode){ e.preventDefault(); tabs.children[iTab].focus(); } return; } var curMillis = Date.now(); if(curMillis-lastKeys_millis>1000) lastKeys = null; lastKeys_millis = curMillis; switch (key) { case "!": case "/": case ":": inputE.value = ""; inputE.focus(); lastKeys = null; return; } lastKeys = !lastKeys ? key : lastKeys + key; let cmds = mapKeys[lastKeys]; if(cmds){//try to run cmds let keyLen = lastKeys.length; setTimeout(()=>{ if(lastKeys.length != keyLen) return; lastKeys = null; handleQueries(cmds); }, 500); } } function getQ(){return document.forms[0].q.value;} function bang(query, iSpace){ let se=defaultSE; { let name = query.slice(0,iSpace); let engine = engines[name]; if(engine){ se = engine; query = query.substring(iSpace+1); } } return se.replace('%s',query); } function coloncommand(q){ ipcRenderer.send("command",q); } function coloncommand_render(cmd){ const delimiter = /\s+/; let arg0; let argrest = null; const match = delimiter.exec(cmd); if(match){ const sIndex = match.index; const eIndex = sIndex + match[0].length; arg0 = cmd.substring(1,sIndex); argrest = cmd.substring(eIndex); }else arg0 = cmd.substring(1); switch(arg0){ case "ac": autoc(argrest); return; case "b": bookmark(argrest); return; case "bjs": eval(argrest); return; case "bml": bangcommand(argrest,0); return; case "pdf": savePdf(argrest); return; } } function autoc(argrest){ if(!argrest) return; let fpath = path.join(__dirname,argrest); let fname = fpath; let delimit = ' '; if (!fs.existsSync(fname)){ fname = fpath+".autoc"; if (!fs.existsSync(fname)) fname = fpath+".rec"; else delimit = null; } appendAutoc_rec(fname,delimit); } function bml(args){ if(2!=args.length) return; let filename = args[1]+".js"; fs.readFile(path.join(__dirname,filename), 'utf8', (err,str) => { if (err) return; tabs.children[iTab].executeJavaScript(str,false); }); } function savePdf(argrest){ let filename = "ebrowser.pdf"; let options = {}; if(argrest){ let c0 = argrest.charCodeAt(0); let iOpts = 0; if(123!=c0){//not '{' options then it is filename let iS = argrest.indexOf(' '); let filestem = argrest; if(iS>0) { filestem = argrest.substring(0,iS); iOpts = iS+1; }else iOpts = argrest.length; filename = filestem + ".pdf"; } if(argrest.length>iOpts){//:Pdf [filename] {...} if(iOpts+2==argrest.length){// '{}' let width = document.body.clientWidth/96; tabs.children[iTab].executeJavaScript("document.documentElement.scrollHeight", false).then((h)=>{ let opts = { printBackground:true, pageSize:{width:width,height:h/96}}; print2PDF(filename,opts); }); return; }else{ try { options = JSON.parse(argrest.substring(iOpts)); }catch(e){}; } } } print2PDF(filename,options); } function bangcommand(q,offset){ let iS = q.indexOf(' ',offset); if(iS<0) iS=q.length; let fname = q.substring(offset,iS); let fpath = path.join(__dirname,fname+'.js'); if (fs.existsSync(fpath)) { fs.readFile(fpath, 'utf8',(err, js)=>{ if (err) { console.log(err); return; } const prefix = "(function(){"; const postfix = "})(`"; const end ="`)"; const fjs = `${prefix}${js}${postfix}${q}${end}`; tabs.children[iTab].executeJavaScript(fjs,false); }); } } function recQueryHistory(q){ if(bQueryHistory) fs.appendFile(path.join(__dirname,"history.autoc"), q, (err)=>{}); } function handleQuery(q){ if(q.length>1){ let c0=q.charCodeAt(0); let c1=q.charCodeAt(1); switch(c0){ case 33://"!" if(33!=c1) coloncommand(q); else bangcommand(q,2); recQueryHistory(q); return; case 47://"/" tabs.children[iTab].findInPage(q.substring(1)); return; case 58://':' if(c1>98 && 112!=c1) coloncommand(q); else coloncommand_render(q); recQueryHistory(q); return; } if(58===c1){ internalLink(q); return; } } var url=q; NOREC: do{ do { if(q.length>12){ let c6 = q.charCodeAt(6); if(47===c6){// '/' let c5 = q.charCodeAt(5); if(47===c5 && 58===q.charCodeAt(4))//http/file urls break NOREC; if(58===c5 && 47===q.charCodeAt(7))//https:// break NOREC; }else if(q.startsWith("javascript:")){ tabs.children[iTab].executeJavaScript(q.substring(11),false); recQueryHistory(q); return; }else if(q.startsWith("view-source:")) break; else if(q.startsWith("data:")) break; } let iS = q.indexOf(' '); if(iS<0){ if(q.length>5 && 58===q.charCodeAt(5)){// about: break; } if(q.indexOf('.')>0){ url = 'http://'+q; break NOREC; } url = defaultSE.replace('%s',q); break; } url = bang(q, iS); if(58===url.charCodeAt(1)){ internalLink(url); recQueryHistory(q); return; } }while(false); recQueryHistory(q); }while(false); tabs.children[iTab].src=url; } function internalLink(url){ let cmd = url.charCodeAt(2); let subcmd = url.charCodeAt(3); switch(cmd){ case 48://'0' i:0 { let iColon = url.indexOf(':',5); let name = decodeURIComponent(url.slice(4,iColon)); let rurl = url.substring(iColon+1); switch(subcmd){ case 47://'/' i:0/ if(106===url.charCodeAt(4) && 115===url.charCodeAt(5) && 47===url.charCodeAt(6)){ //i:0/js/xx:[url] let fname = name; let pname = path.join(__dirname,fname); if(fs.existsSync(pname)){ (async ()=>{ try { let js = await fs.promises.readFile(pname,'utf8'); let t=tabs.children[iTab]; t.dataset.jsonce=js; t.src = rurl; }catch(e){} })(); } } return; case 48: //i:00 { let endS; let is = url.indexOf('%s',iColon+1); if(is<0) endS = '%s"\n'; else endS = '"\n'; let pname = path.join(__dirname,"search.json"); let str = '"'+name+'":"'+rurl+ endS; jsonAppend(pname,125,str); } return; case 49: //i:01 { let pname = path.join(__dirname,"menu.json"); let str = '"'+name+'",":bjs handleQuery(`'+ rurl+'${tabs.children[iTab].getURL()}`)"\n'; jsonAppend(pname,93,str); } return; case 50: //i:02 { let pname = path.join(__dirname,"uas.json"); let str = '"'+name+'":"'+rurl+'"\n'; jsonAppend(pname,125,str); } return; case 85: {//i:0U let sscmd = url.charCodeAt(4); switch(sscmd){ case 65: {//i:0UA let iC = url.indexOf(':',5); let ua = navigator.userAgent; ua += ' '+url.substring(5,iC); tabs.children[iTab].setUserAgent(ua); handleQuery(url.substring(iC+1)); } return; } } return; default: } } return; case 56: //i:8 switch(subcmd){ case 100: //i:8d[url] to download tabs.children[iTab].downloadURL(url.substring(4)); return; } return; case 112: //i:p[url]#[querystring] for http POST { let iQ = url.indexOf('#',12); let data; let rurl; let headers; switch(subcmd){ case 49: //i:p1 { if(iQ>4) { rurl = url.substring(4,iQ); data = [{ type: 'rawData', bytes: Buffer.from(url.substring(iQ+1)) }]; }else{ rurl = url.substring(4); data = []; } headers = 'Content-Type: application/x-www-form-urlencoded'; break; } case 50: //i:p2[url]#[filePath] post request with local file rurl = url.substring(4,iQ); data = [{type: 'file', filePath:url.substring(iQ+1)}]; headers = 'Content-Type: multipart/form-data'; break; default: return; } tabs.children[iTab].loadURL(rurl,{postData:data,extraHeaders:headers}); return; } case 113: //i:q for multiple queries handleQueries(url.substring(3)); return; } } function handleQueries(cmds){ for(var cmd of cmds.split("\n")) handleQuery(cmd); } async function jsonAppend(filePath, charcode, str){ let fd; try{ fd = await fs.promises.open(filePath, 'r+'); }catch(e){ try { fd = await fs.promises.open(filePath, 'w+'); }catch(e1){return} } try{ const stats = await fd.stat(); const fileSize = stats.size; const buffer = Buffer.alloc(1); let position = fileSize-1; while (position >= 0) { await fd.read(buffer, 0, 1, position); if (buffer[0] === charcode) break; position--; } let endS = String.fromCharCode(charcode); if(position<0){//re-write whole file str = String.fromCharCode(charcode-2)+str+endS; position = 0; }else str = ","+str+endS; await fd.truncate(position); const buf = Buffer.from(str); await fd.write(buf, 0, buf.length, position); await fd.close(); }catch(e){console.log(e)} } function autocomplete(inp,container,arr) { var currentFocus; function clickItem(e){inp.value = arr[e.target.dataset.index];} function appendElement(el,dataindex){ el.dataset.index = dataindex; el.addEventListener("click", clickItem); container.appendChild(el); } function itemInnerHTML(b,str,val,iStr){ b.innerHTML = str.substr(0,iStr); b.innerHTML += "<strong>" + str.substr(iStr, val.length) + "</strong>"; b.innerHTML += str.substr(iStr+val.length); } inp.addEventListener("input", function(e) { const MAXITEMS = 30; var b, i, val = this.value; if (!val) { return false;} currentFocus = -1; let items = container.children; let iBase = 0; let lastV = lastVal; lastVal = val; if(val.startsWith(lastV)){ let itemsLen = items.length; if(itemsLen<=0) return; i = itemsLen -1; iBase = items[i].dataset.index +1; switch(autocMode){ case 0: for (; i>=0; i--) { b = items[i]; let str = arr[b.dataset.index]; let iStr = str.indexOf(val); if(iStr<0) { b.parentNode.removeChild(b); continue; } itemInnerHTML(b,str,val,iStr); } break; case 1: for (; i>=0; i--) { b = items[i]; let str = arr[b.dataset.index]; let oLen = lastval.length; if(!str.startsWith(val.substring(oLen),oLen)) { b.parentNode.removeChild(b); continue; } itemInnerHTML(b,str,val,0); } break; } if(itemsLen<MAXITEMS) return; else if(container.children.length>=MAXITEMS) return; }else closeAllLists(); i = iBase; switch(autocMode){ case 0: for (; i < arr.length; i++) { let iStr = arr[i].indexOf(val); if(iStr<0) continue; { b = document.createElement("DIV"); itemInnerHTML(b,arr[i],val,iStr); appendElement(b,i); if(container.children.length>=MAXITEMS) break; } } return; case 1://startsWith for (; i < arr.length; i++) { if (arr[i].startsWith(val)) { b = document.createElement("DIV"); itemInnerHTML(b,arr[i],val,0); appendElement(b,i); if(container.children.length>=MAXITEMS) break; } } } }); inp.addEventListener("keydown", function(e) { var x = container.getElementsByTagName("div"); if (0===x.length) return false; if (e.keyCode == 40) {//downarrow currentFocus++; addActive(x); } else if (e.keyCode == 38) { //up currentFocus--; addActive(x); } else if (e.keyCode == 13) { if (currentFocus > -1) { e.preventDefault(); if (x) x[currentFocus].click(); currentFocus = -1; } closeAllLists(); lastVal = null; } }); function addActive(x) { removeActive(x); if (currentFocus >= x.length) currentFocus = 0; if (currentFocus < 0) currentFocus = (x.length - 1); x[currentFocus].classList.add("autocomplete-active"); } function removeActive(x) { for (var i = 0; i < x.length; i++) { x[i].classList.remove("autocomplete-active"); } } function closeAllLists() { container.innerHTML = ''; } inp.addEventListener("blur", function () { setTimeout(()=>container.classList.add("invis"),200); }); inp.addEventListener("focus", function () { container.classList.remove("invis"); }); } </script> </head> <body> <form class="autocomplete" autocomplete="off" action="javascript:handleQuery(getQ())"> <input type="text" name=q style="width:100%" autofocus> <div class="autocomplete-items"></div> </form> <div class="webviews"> <webview class="curWV" src="about:blank" allowpopups></webview> </div> <script> tabs = document.body.children[1]; initTab(tabs.children[0]); { let inp = document.forms[0].q; autocomplete(inp,inp.nextElementSibling,autocStrArray); } document.addEventListener('keydown', keyPress); </script> </body></html>