/**
* Holesail Dashboard - UI logic
* Guard: only run when loaded as the actual dashboard page.
*/
if (!document.getElementById('page-dashboard') && !document.querySelector('.sidebar-logo')) {
throw new Error('dashboard.js loaded outside dashboard context — aborting');
}
const SETTINGS_DEFAULTS = {
proxyPort: 8443,
connectProxyPort: 8442,
readyTimeoutMs: 0,
notifyOnDisconnect: true,
debug: false,
disableOnFileUrls: false,
backupRetention: 5
};
let currentState = null;
let settings = { ...SETTINGS_DEFAULTS };
function $(id) { return document.getElementById(id); }
function log(...args) { console.log('[Holesail-dashboard]', ...args); }
// ── Utilities ──────────────────────────────────────────────────────────────
function timeAgo(timestamp) {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return seconds + 's ago';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + 'm ago';
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + 'h ago';
return Math.floor(hours / 24) + 'd ago';
}
function formatUptime(ms) {
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return seconds + 's';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + 'm ' + (seconds % 60) + 's';
const hours = Math.floor(minutes / 60);
return hours + 'h ' + (minutes % 60) + 'm';
}
function truncate(str, len = 20) {
if (!str) return '';
return str.length > len ? str.slice(0, len) + '…' : str;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ── Virtual host TLD validator ────────────────────────────────────────────────
// Real single-label TLDs that must be blocked (compact subset of IANA list)
const REAL_TLDS = new Set([
'ac','ad','ae','af','ag','ai','al','am','ao','aq','ar','as','at','au','aw','ax','az',
'ba','bb','bd','be','bf','bg','bh','bi','bj','bm','bn','bo','br','bs','bt','bv','bw',
'by','bz','ca','cc','cd','cf','cg','ch','ci','ck','cl','cm','cn','co','cr','cu','cv',
'cw','cx','cy','cz','de','dj','dk','dm','do','dz','ec','ee','eg','er','es','et','eu',
'fi','fj','fk','fm','fo','fr','ga','gb','gd','ge','gf','gg','gh','gi','gl','gm','gn',
'gp','gq','gr','gs','gt','gu','gw','gy','hk','hm','hn','hr','ht','hu','id','ie','il',
'im','in','io','iq','ir','is','it','je','jm','jo','jp','ke','kg','kh','ki','km','kn',
'kp','kr','kw','ky','kz','la','lb','lc','li','lk','lr','ls','lt','lu','lv','ly','ma',
'mc','md','me','mg','mh','mk','ml','mm','mn','mo','mp','mq','mr','ms','mt','mu','mv',
'mw','mx','my','mz','na','nc','ne','nf','ng','ni','nl','no','np','nr','nu','nz','om',
'pa','pe','pf','pg','ph','pk','pl','pm','pn','pr','ps','pt','pw','py','qa','re','ro',
'rs','ru','rw','sa','sb','sc','sd','se','sg','sh','si','sj','sk','sl','sm','sn','so',
'sr','ss','st','su','sv','sx','sy','sz','tc','td','tf','tg','th','tj','tk','tl','tm',
'tn','to','tr','tt','tv','tw','tz','ua','ug','uk','us','uy','uz','va','vc','ve','vg',
'vi','vn','vu','wf','ws','ye','yt','za','zm','zw',
// Generic TLDs
'aaa','aarp','abb','abc','able','abogado','abudhabi','academy','accenture','accountant',
'accountants','aco','actor','ads','adult','aeg','aero','aetna','africa','agakhan','agency',
'aig','airbus','airforce','airtel','akdn','alfaromeo','alibaba','alipay','allfinanz',
'allstate','ally','alsace','alstom','amazon','americanexpress','americanfamily','amex',
'amfam','amica','amsterdam','analytics','android','anquan','anz','aol','apartments',
'app','apple','aquarelle','arab','aramco','archi','army','art','arte','asda','associates',
'athleta','auction','audi','audible','audio','auspost','author','auto','autos','avianca',
'aws','axa','azure','baby','baidu','banamex','band','bank','bar','barcelona','barclaycard',
'barclays','barefoot','bargains','baseball','basketball','bauhaus','bayern','bbc','bbt',
'bbva','bcg','bcn','beats','beauty','beer','bentley','berlin','best','bestbuy','bet',
'bible','bid','bike','bing','bingo','bio','black','blackfriday','blockbuster','blog',
'bloomberg','blue','bms','bmw','bnl','bnpparibas','boats','boehringer','bofa','bom',
'bond','boo','book','booking','bosch','bostik','boston','bot','boutique','box','bradesco',
'bridgestone','broadway','broker','brother','brussels','budapest','bugatti','build',
'builders','business','buy','buzz','bzh','cab','cafe','cal','call','calvinklein','cam',
'camera','camp','cancerresearch','canon','capetown','capital','capitalone','cards','care',
'career','careers','cars','casa','case','cash','casino','cat','catering','catholic','cba',
'cbn','cbre','cbs','center','ceo','cern','cfa','cfd','channel','charity','chase','chat',
'cheap','chintai','christmas','chrome','church','cipriani','circle','cisco','citi',
'citic','city','cityeats','claims','cleaning','click','clinic','clinique','clothing',
'cloud','club','clubmed','codes','coffee','college','cologne','com','community','company',
'compare','computer','comsec','condos','construction','consulting','contact','contractors',
'cooking','cool','coop','corsica','country','coupon','coupons','courses','credit',
'creditcard','creditunion','cricket','crown','crs','cruise','cruises','csc','cuisinella',
'cymru','cyou','dabur','dad','dance','data','date','dating','datsun','day','dclk','dds',
'deal','dealer','deals','degree','delivery','dell','deloitte','democrat','dental','design',
'dev','dhl','diamonds','diet','digital','direct','directory','discount','discover',
'dish','diy','dnp','docs','doctor','dog','domains','dot','download','drive','dtv','dubai',
'dunlop','dupont','durban','dvag','dvr','earth','eat','eco','edeka','edu','education',
'email','emerck','energy','engineering','enterprises','epson','equipment','ericsson',
'erni','estate','esurance','etisalat','eurovision','eus','events','exchange','expert',
'exposed','express','extraspace','fage','fail','fairwinds','faith','family','fan','fans',
'farm','farmers','fashion','fast','fedex','feedback','ferrari','ferrero','fiat','fidelity',
'fido','film','final','finance','financial','fire','firestone','firmdale','fish','fishing',
'fit','fitness','flights','florist','flowers','fly','foo','food','foodnetwork','football',
'ford','forex','forsale','forum','foundation','fox','free','fresenius','frl','frogans',
'frontdoor','frontier','ftr','fujitsu','fun','fund','furniture','futbol','fyi','gal',
'gallery','gallo','gallup','game','games','gap','garden','gay','gbiz','gdn','gea',
'gent','genting','george','ggee','gift','gifts','gives','giving','glass','gle','global',
'globo','gmail','gmbh','gold','goldpoint','golf','goo','goodyear','goog','google','gop',
'got','gov','grainger','graphics','gratis','green','gripe','grocery','group','guardian',
'gucci','guge','guide','guitars','guru','hair','hamburg','hangout','haus','hbo','hdfc',
'hdfcbank','health','healthcare','help','helsinki','here','hermes','hgtv','hiphop',
'hisamitsu','hitachi','hiv','hkt','hockey','holdings','holiday','homedepot','homegoods',
'homes','homesense','honda','horse','hospital','host','hosting','hot','hoteles','hotels',
'hotmail','house','how','hsbc','hughes','hyatt','hyundai','ibm','icbc','ice','icu','ieee',
'ifm','ikano','imamat','imdb','immo','immobilien','industries','infiniti','info','ing',
'ink','institute','insurance','insure','int','international','intuit','investments','ipiranga',
'irish','ismaili','ist','istanbul','itau','itv','jaguar','java','jcb','jeep','jetzt',
'jewelry','jio','jll','jobs','joburg','jot','joy','jpmorgan','jprs','juegos','juniper',
'kaufen','kddi','kerryhotels','kerrylogistics','kerryproperties','kfh','kia','kids','kim',
'kinder','kindle','kitchen','kiwi','koeln','komatsu','kosher','kpmg','kpn','krd','kred',
'kuokgroup','kyoto','lacaixa','lamborghini','lamer','lancaster','land','landrover',
'lanxess','lasalle','lat','latino','latrobe','law','lawyer','lds','lease','leclerc',
'lefrak','legal','lego','lexus','lgbt','lidl','life','lifeinsurance','lifestyle','lighting',
'like','lilly','limited','limo','lincoln','link','lipsy','live','living','llc','llp',
'loan','loans','locker','locus','lol','london','lotte','lotto','love','lpl','lplfinancial',
'ltd','ltda','lundbeck','luxe','luxury','madrid','maif','maison','makeup','man','management',
'mango','map','market','marketing','markets','marriott','marshalls','mba','mckinsey','med',
'media','meet','melbourne','meme','memorial','men','menu','merckmsd','miami','microsoft',
'mil','mini','mint','mit','mitsubishi','mobi','mobile','moda','moe','moi','mom','monash',
'money','monster','mormon','mortgage','moscow','moto','motorcycles','mov','movie','msd',
'mtn','mtr','music','mutual','nab','nagoya','name','natura','navy','nba','net','netbank',
'netflix','network','neustar','new','news','next','nextdirect','nexus','nfl','ngo','nhk',
'nico','nike','nikon','ninja','nissan','nissay','nokia','norton','now','nowruz','nra',
'nrw','ntt','nyc','obi','observer','office','okinawa','olayan','olayangroup','oldnavy',
'ollo','omega','one','ong','onl','online','ooo','open','oracle','orange','org','organic',
'origins','osaka','otsuka','ott','ovh','page','panasonic','paris','pars','partners','parts',
'party','passagens','pay','pccw','pet','pfizer','pharmacy','phd','philips','phone','photo',
'photography','photos','physio','pics','pictet','pictures','pid','pin','ping','pink',
'pioneer','pizza','place','play','playstation','plumbing','plus','pnc','pohl','poker',
'politie','porn','post','pramerica','praxi','press','prime','pro','prod','productions',
'prof','progressive','promo','properties','property','protection','pru','prudential',
'pub','pwc','qpon','quebec','quest','racing','radio','read','realestate','realtor',
'realty','recipes','red','redstone','redumbrella','rehab','reise','reisen','reit','reliance',
'ren','rent','rentals','repair','report','republican','rest','restaurant','review','reviews',
'rexroth','rich','richardli','ricoh','rightathome','rio','rip','rocher','rocks','rodeo',
'rogers','room','rsvp','rugby','ruhr','run','rwe','ryukyu','safe','safety','sakura','sale',
'salon','samsclub','samsung','sandvik','sandvikcoromant','sanofi','sap','sarl','sas',
'save','saxo','sbi','sbs','sca','scb','schaeffler','schmidt','scholarships','school',
'schule','schwarz','science','scjohnson','scot','search','seat','secure','security',
'seek','select','sener','services','ses','seven','sew','sex','sexy','sfr','shangrila',
'sharp','shaw','shell','shia','shiksha','shoes','shop','shopping','shouji','show','silk',
'sina','singles','ski','skin','sky','skype','smile','sncf','soccer','social','softbank',
'software','sohu','solar','solutions','song','sony','soy','spa','space','sport','spot',
'srl','stada','staples','star','statebank','statefarm','stc','stcgroup','stockholm',
'storage','store','stream','studio','study','style','sucks','supplies','supply','support',
'surf','surgery','suzuki','swatch','swiss','sydney','systems','tab','taipei','talk',
'taobao','target','tatamotors','tatar','tattoo','tax','taxi','tci','tdk','team','tech',
'technology','tel','temasek','tennis','teva','tiaa','tickets','tienda','tips','tires',
'tirol','tjmaxx','tjx','tkmaxx','today','tokyo','tools','top','toray','toshiba','total',
'tours','town','toyota','toys','trade','trading','training','travel','travelers',
'travelersinsurance','trust','trv','tube','tui','tunes','tushu','tvs','ubank','unicom',
'university','uno','uol','ups','vacations','vana','vanguard','vegas','ventures','verisign',
'versicherung','vet','viajes','video','vig','viking','villas','vin','vip','virgin','visa',
'vision','viva','vivo','vlaanderen','vodka','volkswagen','volvo','vote','voting','voto',
'voyage','vuelos','wales','walmart','walter','wang','wanggou','watch','watches','weather',
'weatherchannel','webcam','weber','website','wed','wedding','weibo','weir','whoswho',
'wien','wiki','williamhill','win','windows','wine','winners','wme','wolterskluwer',
'woodside','work','works','world','wow','wtc','wtf','xbox','xerox','xfinity','xihuan',
'xin','xxx','xyz','yachts','yahoo','yamaxun','yandex','yodobashi','yoga','yokohama',
'you','youtube','yun','zappos','zara','zero','zip','zone','zuerich'
]);
// Real two-label public suffixes (e.g. co.uk) — base domain check
const REAL_SLD_TLDS = new Set([
'co.uk','org.uk','me.uk','net.uk','ltd.uk','plc.uk','sch.uk','gov.uk','nhs.uk','police.uk',
'com.au','net.au','org.au','edu.au','gov.au','asn.au','id.au',
'co.nz','net.nz','org.nz','edu.nz','govt.nz','geek.nz','gen.nz','maori.nz',
'co.za','org.za','net.za','edu.za','gov.za','web.za',
'com.br','net.br','org.br','edu.br','gov.br','mil.br',
'com.ar','net.ar','org.ar','edu.ar','gov.ar',
'com.mx','net.mx','org.mx','edu.mx','gob.mx',
'com.cn','net.cn','org.cn','edu.cn','gov.cn','ac.cn',
'co.jp','ne.jp','or.jp','ac.jp','go.jp','ad.jp',
'co.in','net.in','org.in','edu.in','gov.in','ac.in','res.in',
'co.ke','or.ke','ac.ke','go.ke','ne.ke',
'com.sg','net.sg','org.sg','edu.sg','gov.sg',
'com.hk','net.hk','org.hk','edu.hk','gov.hk',
'com.tw','net.tw','org.tw','edu.tw','gov.tw',
'com.my','net.my','org.my','edu.my','gov.my',
'com.pk','net.pk','org.pk','edu.pk','gov.pk',
'com.ng','net.ng','org.ng','edu.ng','gov.ng',
'com.gh','net.gh','org.gh','edu.gh','gov.gh',
'com.eg','net.eg','org.eg','edu.eg','gov.eg',
'com.tr','net.tr','org.tr','edu.tr','gov.tr',
'com.sa','net.sa','org.sa','edu.sa','gov.sa',
'com.ae','net.ae','org.ae','edu.ae','gov.ae',
'com.il','net.il','org.il','edu.il','gov.il',
'co.il','ac.il',
'com.ua','net.ua','org.ua','edu.ua','gov.ua',
'com.pl','net.pl','org.pl','edu.pl','gov.pl',
'com.de','net.de','org.de',
'com.fr','net.fr','org.fr',
'com.es','net.es','org.es','edu.es','gob.es',
'com.it','net.it','org.it','edu.it','gov.it',
'com.ru','net.ru','org.ru','edu.ru','gov.ru',
'com.vn','net.vn','org.vn','edu.vn','gov.vn',
'com.ph','net.ph','org.ph','edu.ph','gov.ph',
'com.id','net.id','org.id','edu.id','go.id',
'com.pe','net.pe','org.pe','edu.pe','gob.pe',
'com.co','net.co','org.co','edu.co','gov.co',
'com.ve','net.ve','org.ve','edu.ve','gov.ve',
'com.ec','net.ec','org.ec','edu.ec','gov.ec',
'com.bo','net.bo','org.bo','edu.bo','gov.bo',
'com.py','net.py','org.py','edu.py','gov.py',
'com.uy','net.uy','org.uy','edu.uy','gub.uy',
'com.ni','net.ni','org.ni','edu.ni','gob.ni',
'com.cr','net.cr','org.cr','edu.cr','go.cr',
'com.gt','net.gt','org.gt','edu.gt','gob.gt',
'com.hn','net.hn','org.hn','edu.hn','gob.hn',
'com.sv','net.sv','org.sv','edu.sv','gob.sv',
'com.pa','net.pa','org.pa','edu.pa','gob.pa',
'com.do','net.do','org.do','edu.do','gob.do',
'com.cu','net.cu','org.cu','edu.cu','inf.cu',
'com.pr','net.pr','org.pr','edu.pr','gov.pr',
'com.tt','net.tt','org.tt','edu.tt','gov.tt',
'com.jm','net.jm','org.jm','edu.jm','gov.jm',
'com.bb','net.bb','org.bb','edu.bb','gov.bb',
'com.lc','net.lc','org.lc','edu.lc','gov.lc',
'com.vc','net.vc','org.vc','edu.vc','gov.vc',
'com.dm','net.dm','org.dm','edu.dm','gov.dm',
'com.ag','net.ag','org.ag','edu.ag','gov.ag',
'com.kn','net.kn','org.kn','edu.kn','gov.kn',
'com.gd','net.gd','org.gd','edu.gd','gov.gd',
'com.ms','net.ms','org.ms','edu.ms','gov.ms',
'com.ai','net.ai','org.ai','edu.ai','gov.ai',
'com.vg','net.vg','org.vg','edu.vg','gov.vg',
'com.ky','net.ky','org.ky','edu.ky','gov.ky',
'com.tc','net.tc','org.tc','edu.tc','gov.tc',
'com.bm','net.bm','org.bm','edu.bm','gov.bm',
'com.bs','net.bs','org.bs','edu.bs','gov.bs',
'com.aw','net.aw','org.aw',
'com.na','net.na','org.na','edu.na','gov.na',
'com.zm','net.zm','org.zm','edu.zm','gov.zm',
'com.zw','net.zw','org.zw','edu.zw','gov.zw',
'com.mz','net.mz','org.mz','edu.mz','gov.mz',
'com.tz','net.tz','org.tz','edu.tz','go.tz',
'com.ug','net.ug','org.ug','edu.ug','go.ug',
'com.rw','net.rw','org.rw','edu.rw','gov.rw',
'com.et','net.et','org.et','edu.et','gov.et',
'com.sd','net.sd','org.sd','edu.sd','gov.sd',
'com.ly','net.ly','org.ly','edu.ly','gov.ly',
'com.tn','net.tn','org.tn','edu.tn','gov.tn',
'com.dz','net.dz','org.dz','edu.dz','gov.dz',
'com.ma','net.ma','org.ma','edu.ma','gov.ma',
'com.sn','net.sn','org.sn','edu.sn','gov.sn',
'com.ci','net.ci','org.ci','edu.ci','gov.ci',
'com.cm','net.cm','org.cm','edu.cm','gov.cm',
'com.bf','net.bf','org.bf','edu.bf','gov.bf',
'com.ml','net.ml','org.ml','edu.ml','gov.ml',
'com.ne','net.ne','org.ne','edu.ne','gov.ne',
'com.td','net.td','org.td','edu.td','gov.td',
'com.mr','net.mr','org.mr','edu.mr','gov.mr',
'com.gn','net.gn','org.gn','edu.gn','gov.gn',
'com.sl','net.sl','org.sl','edu.sl','gov.sl',
'com.lr','net.lr','org.lr','edu.lr','gov.lr',
'com.gw','net.gw','org.gw','edu.gw','gov.gw',
'com.gm','net.gm','org.gm','edu.gm','gov.gm',
'com.cv','net.cv','org.cv','edu.cv','gov.cv',
'com.st','net.st','org.st','edu.st','gov.st',
'com.ga','net.ga','org.ga','edu.ga','gov.ga',
'com.cg','net.cg','org.cg','edu.cg','gov.cg',
'com.cd','net.cd','org.cd','edu.cd','gov.cd',
'com.ao','net.ao','org.ao','edu.ao','gov.ao',
'com.bw','net.bw','org.bw','edu.bw','gov.bw',
'com.ls','net.ls','org.ls','edu.ls','gov.ls',
'com.sz','net.sz','org.sz','edu.sz','gov.sz',
'com.mg','net.mg','org.mg','edu.mg','gov.mg',
'com.mu','net.mu','org.mu','edu.mu','gov.mu',
'com.re','net.re','org.re','edu.re','gov.re',
'com.yt','net.yt','org.yt',
'com.sc','net.sc','org.sc','edu.sc','gov.sc',
'com.km','net.km','org.km','edu.km','gov.km',
'com.dj','net.dj','org.dj','edu.dj','gov.dj',
'com.so','net.so','org.so','edu.so','gov.so',
'com.er','net.er','org.er','edu.er','gov.er',
'com.bi','net.bi','org.bi','edu.bi','gov.bi',
'com.mw','net.mw','org.mw','edu.mw','gov.mw',
'com.zm','net.zm','org.zm',
'com.bd','net.bd','org.bd','edu.bd','gov.bd',
'com.lk','net.lk','org.lk','edu.lk','gov.lk',
'com.np','net.np','org.np','edu.np','gov.np',
'com.mm','net.mm','org.mm','edu.mm','gov.mm',
'com.kh','net.kh','org.kh','edu.kh','gov.kh',
'com.la','net.la','org.la','edu.la','gov.la',
'com.vn','net.vn','org.vn','edu.vn','gov.vn',
'com.mn','net.mn','org.mn','edu.mn','gov.mn',
'com.kz','net.kz','org.kz','edu.kz','gov.kz',
'com.uz','net.uz','org.uz','edu.uz','gov.uz',
'com.tm','net.tm','org.tm','edu.tm','gov.tm',
'com.tj','net.tj','org.tj','edu.tj','gov.tj',
'com.kg','net.kg','org.kg','edu.kg','gov.kg',
'com.af','net.af','org.af','edu.af','gov.af',
'com.pk','net.pk','org.pk','edu.pk','gov.pk',
'com.ir','net.ir','org.ir','edu.ir','gov.ir',
'com.iq','net.iq','org.iq','edu.iq','gov.iq',
'com.sy','net.sy','org.sy','edu.sy','gov.sy',
'com.lb','net.lb','org.lb','edu.lb','gov.lb',
'com.jo','net.jo','org.jo','edu.jo','gov.jo',
'com.ps','net.ps','org.ps','edu.ps','gov.ps',
'com.ye','net.ye','org.ye','edu.ye','gov.ye',
'com.om','net.om','org.om','edu.om','gov.om',
'com.kw','net.kw','org.kw','edu.kw','gov.kw',
'com.bh','net.bh','org.bh','edu.bh','gov.bh',
'com.qa','net.qa','org.qa','edu.qa','gov.qa',
'com.ge','net.ge','org.ge','edu.ge','gov.ge',
'com.am','net.am','org.am','edu.am','gov.am',
'com.az','net.az','org.az','edu.az','gov.az',
'com.by','net.by','org.by','edu.by','gov.by',
'com.ua','net.ua','org.ua','edu.ua','gov.ua',
'com.md','net.md','org.md','edu.md','gov.md',
'com.ro','net.ro','org.ro','edu.ro','gov.ro',
'com.bg','net.bg','org.bg','edu.bg','gov.bg',
'com.mk','net.mk','org.mk','edu.mk','gov.mk',
'com.al','net.al','org.al','edu.al','gov.al',
'com.rs','net.rs','org.rs','edu.rs','gov.rs',
'com.hr','net.hr','org.hr','edu.hr','gov.hr',
'com.ba','net.ba','org.ba','edu.ba','gov.ba',
'com.me','net.me','org.me','edu.me','gov.me',
'com.si','net.si','org.si','edu.si','gov.si',
'com.sk','net.sk','org.sk','edu.sk','gov.sk',
'com.cz','net.cz','org.cz','edu.cz','gov.cz',
'com.pl','net.pl','org.pl','edu.pl','gov.pl',
'com.hu','net.hu','org.hu','edu.hu','gov.hu',
'com.at','net.at','org.at','edu.at','gov.at',
'com.ch','net.ch','org.ch','edu.ch','gov.ch',
'com.li','net.li','org.li','edu.li','gov.li',
'com.be','net.be','org.be','edu.be','gov.be',
'com.nl','net.nl','org.nl','edu.nl','gov.nl',
'com.lu','net.lu','org.lu','edu.lu','gov.lu',
'com.dk','net.dk','org.dk','edu.dk','gov.dk',
'com.se','net.se','org.se','edu.se','gov.se',
'com.no','net.no','org.no','edu.no','gov.no',
'com.fi','net.fi','org.fi','edu.fi','gov.fi',
'com.is','net.is','org.is','edu.is','gov.is',
'com.ie','net.ie','org.ie','edu.ie','gov.ie',
'com.pt','net.pt','org.pt','edu.pt','gov.pt',
'com.gr','net.gr','org.gr','edu.gr','gov.gr',
'com.cy','net.cy','org.cy','edu.cy','gov.cy',
'com.mt','net.mt','org.mt','edu.mt','gov.mt',
'com.ee','net.ee','org.ee','edu.ee','gov.ee',
'com.lv','net.lv','org.lv','edu.lv','gov.lv',
'com.lt','net.lt','org.lt','edu.lt','gov.lt'
]);
/**
* Validate a virtual host hostname.
* Rules:
* 1. Must have at least 2 dots (3 labels minimum: host.second.tld)
* 2. Each label: only [a-z0-9-], no leading/trailing hyphen, non-empty
* 3. The last two labels (base domain, e.g. "hole.sail") must not be a
* real public TLD or second-level public suffix
* Returns { ok: true } or { ok: false, error: string }
*/
function isValidVhostHostname(hostname) {
if (!hostname) return { ok: false, error: 'Hostname is required' };
const labels = hostname.split('.');
if (labels.length < 3) {
return { ok: false, error: 'Hostname must have the form host.second.tld (e.g. myapp.hole.sail) — single-dot names are not allowed' };
}
for (const label of labels) {
if (!label) return { ok: false, error: 'Hostname contains empty labels' };
if (!/^[a-z0-9-]+$/.test(label)) return { ok: false, error: 'Hostname contains invalid characters — only letters, digits and hyphens allowed' };
if (label.startsWith('-') || label.endsWith('-')) return { ok: false, error: 'Hostname labels must not start or end with a hyphen' };
}
const tld = labels[labels.length - 1];
const baseDomain = labels.slice(-2).join('.');
if (REAL_TLDS.has(tld) || REAL_SLD_TLDS.has(baseDomain)) {
return { ok: false, error: 'The TLD ".' + baseDomain + '" is a real registered domain — use a private TLD like .hole.sail or .my.internal' };
}
return { ok: true };
}
/** Extract the two-label base domain from a hostname (e.g. "hole.sail" from "myapp.hole.sail") */
function extractBaseDomain(hostname) {
const parts = hostname.split('.');
return parts.slice(-2).join('.');
}
/** Extract unique two-label base domains from a list of virtual host objects */
function extractActiveTlds(virtualHosts) {
const seen = new Set();
seen.add('hole.sail'); // always include baseline
for (const v of (virtualHosts || [])) {
if (v.hostname) seen.add(extractBaseDomain(v.hostname));
}
return Array.from(seen).map(b => '.' + b);
}
function stateTag(state) {
const dot = (color) => ``;
if (!state || state === '—') return `${dot('text4')}—`;
if (state === 'ready') return `${dot('green')}ready`;
if (state === 'error') return `${dot('red')}error`;
if (state === 'closed') return `${dot('text4')}closed`;
if (state === 'connecting') return `${dot('amber')}connecting`;
return `${dot('amber')}${escapeHtml(state)}`;
}
// ── Toast ───────────────────────────────────────────────────────────────────
let toastTimer = null;
function showToast(msg, type = 'default') {
const el = $('toast');
if (!el) return;
el.textContent = msg;
el.className = 'toast show' + (type !== 'default' ? ' ' + type : '');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.className = 'toast'; }, 2800);
}
// ── Copy to clipboard ───────────────────────────────────────────────────────
function copyToClipboard(text, btnEl) {
navigator.clipboard.writeText(text).then(() => {
if (btnEl) {
btnEl.classList.add('copied');
setTimeout(() => btnEl.classList.remove('copied'), 1500);
}
showToast('Copied to clipboard', 'success');
}).catch(() => showToast('Copy failed', 'error'));
}
// ── Modals ──────────────────────────────────────────────────────────────────
function openModal(id) {
const el = $(id);
if (!el) return;
el.classList.add('open');
// Focus first input
setTimeout(() => {
const input = el.querySelector('input:not([type="checkbox"])');
if (input) input.focus();
}, 50);
}
function closeModal(id) {
const el = $(id);
if (!el) return;
el.classList.remove('open');
// Clear errors
el.querySelectorAll('.modal-error').forEach(e => { e.style.display = 'none'; e.textContent = ''; });
}
function showModalError(modalId, errorId, msg) {
const el = $(errorId);
if (!el) return;
el.textContent = msg;
el.style.display = 'block';
}
// Close modals on backdrop click or close button
document.addEventListener('click', (e) => {
// Close button
const closeBtn = e.target.closest('[data-close-modal]');
if (closeBtn) {
closeModal(closeBtn.dataset.closeModal);
return;
}
// Backdrop click (clicking the backdrop itself, not the modal)
if (e.target.classList.contains('modal-backdrop')) {
closeModal(e.target.id);
}
// Page link button
const pageLink = e.target.closest('[data-page-link]');
if (pageLink) {
navigateTo(pageLink.dataset.pageLink);
}
});
// Escape key closes topmost open modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const open = document.querySelector('.modal-backdrop.open');
if (open) closeModal(open.id);
}
});
// ── Navigation ──────────────────────────────────────────────────────────────
const PAGE_TITLES = {
dashboard: 'Overview',
connections: 'Virtual Hosts',
swarms: 'Server Tunnels',
'service-tunnels': 'Service Tunnels',
tabs: 'Proxy & CA',
ssh: 'SSH Connections',
rdp: 'Remote Desktop',
backups: 'Backups',
logs: 'Logs',
settings: 'Settings'
};
function navigateTo(page) {
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
const navItem = document.querySelector(`.nav-item[data-page="${page}"]`);
if (navItem) navItem.classList.add('active');
const pageEl = $(`page-${page}`);
if (pageEl) pageEl.classList.add('active');
const titleEl = $('topbarTitle');
if (titleEl) titleEl.textContent = PAGE_TITLES[page] || page;
}
function setupNavigation() {
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => navigateTo(item.dataset.page));
});
}
// ── Settings ────────────────────────────────────────────────────────────────
function updateSettingsUI() {
$('toggleNotify')?.classList.toggle('active', settings.notifyOnDisconnect === true);
$('toggleDebug')?.classList.toggle('active', settings.debug === true);
$('toggleDisableFileUrls')?.classList.toggle('active', settings.disableOnFileUrls === true);
const proxyPortEl = $('proxyPort');
const readyTimeoutMsEl = $('readyTimeoutMs');
const backupRetentionEl = $('backupRetention');
if (proxyPortEl) proxyPortEl.value = settings.proxyPort ?? SETTINGS_DEFAULTS.proxyPort;
if (readyTimeoutMsEl) readyTimeoutMsEl.value = settings.readyTimeoutMs ?? SETTINGS_DEFAULTS.readyTimeoutMs;
if (backupRetentionEl) backupRetentionEl.value = settings.backupRetention ?? SETTINGS_DEFAULTS.backupRetention;
}
function saveSettings() {
settings.notifyOnDisconnect = $('toggleNotify')?.classList.contains('active') ?? SETTINGS_DEFAULTS.notifyOnDisconnect;
settings.debug = $('toggleDebug')?.classList.contains('active') ?? SETTINGS_DEFAULTS.debug;
settings.disableOnFileUrls = $('toggleDisableFileUrls')?.classList.contains('active') ?? SETTINGS_DEFAULTS.disableOnFileUrls;
settings.proxyPort = parseInt($('proxyPort')?.value, 10) || SETTINGS_DEFAULTS.proxyPort;
settings.readyTimeoutMs = parseInt($('readyTimeoutMs')?.value, 10) || SETTINGS_DEFAULTS.readyTimeoutMs;
settings.backupRetention = Math.max(1, parseInt($('backupRetention')?.value, 10) || SETTINGS_DEFAULTS.backupRetention);
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'updateSettings', payload: { ...settings } } },
(response) => {
if (chrome.runtime.lastError) { showToast('Settings save failed: ' + chrome.runtime.lastError.message, 'error'); return; }
if (response && response.ok) {
if (response.settings) settings = { ...SETTINGS_DEFAULTS, ...response.settings };
if (response.requiresRestart) {
showToast('Settings saved — restart the native host for proxy port changes to take effect', 'warning');
} else {
showToast('Settings saved', 'success');
}
} else {
showToast(response?.error || 'Failed to save settings', 'error');
}
}
);
}
// ── Remote Desktop ───────────────────────────────────────────────────────────
let rdpConnections = [];
let activeRdpSession = null; // { sessionId, wsPort, type, ws, rfb, conn }
function generateRdpId() {
return 'rdp-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7);
}
function saveRdpConnections(cb) {
// Encode password as base64 before persisting so it survives page reloads
const toSave = rdpConnections.map(c => {
const { password, ...rest } = c; // eslint-disable-line no-unused-vars
if (password) rest.passwordB64 = btoa(unescape(encodeURIComponent(password)));
else delete rest.passwordB64;
return rest;
});
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'setRdpConnections', payload: { connections: toSave } } },
(response) => {
if (response && !response.ok) log('saveRdpConnections failed:', response.error);
renderRdpGrid();
const countEl = $('rdpCount');
if (countEl) countEl.textContent = rdpConnections.length;
if (cb) cb();
}
);
}
function renderRdpGrid() {
const grid = $('rdpGrid');
if (!grid) return;
const countEl = $('rdpCount');
if (countEl) countEl.textContent = rdpConnections.length;
if (rdpConnections.length === 0) {
grid.innerHTML = `
No remote desktop connections
Add a VNC or RDP connection. You'll need an hs:// key for the remote peer.
`;
return;
}
grid.innerHTML = rdpConnections.map(conn => {
const typeBadge = conn.type === 'rdp'
? `RDP`
: `VNC`;
const metaUser = conn.type === 'rdp' && conn.username ? escapeHtml(conn.username) + '@rdp' : conn.type.toUpperCase();
return `
${escapeHtml(conn.label || (conn.type === 'rdp' ? 'RDP Desktop' : 'VNC Desktop'))} ${typeBadge}
`;
}).join('');
grid.querySelectorAll('[data-rdp-connect]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const conn = rdpConnections.find(c => c.id === btn.dataset.rdpConnect);
if (conn) connectRdp(conn);
});
});
grid.querySelectorAll('[data-rdp-edit]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const conn = rdpConnections.find(c => c.id === btn.dataset.rdpEdit);
if (conn) openAddRdpModal(conn);
});
});
grid.querySelectorAll('[data-rdp-remove]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const conn = rdpConnections.find(c => c.id === btn.dataset.rdpRemove);
if (conn) {
$('removeRdpName').textContent = conn.label || conn.type.toUpperCase() + ' Desktop';
$('removeRdpConfirm').dataset.rdpId = conn.id;
openModal('modal-removeRdp');
}
});
});
}
function openAddRdpModal(conn) {
const isEdit = !!conn;
$('modal-addRdp-title').textContent = isEdit ? 'Edit Remote Desktop Connection' : 'Add Remote Desktop Connection';
$('rdpConnLabel').value = conn ? (conn.label || '') : '';
$('rdpConnHsUrl').value = conn ? conn.hsUrl : '';
$('rdpConnPort').value = conn ? conn.port : 5900;
$('rdpConnWidth').value = conn ? (conn.width || 1280) : 1280;
$('rdpConnHeight').value = conn ? (conn.height || 720) : 720;
$('rdpConnUsername').value = conn ? (conn.username || '') : '';
$('rdpConnPassword').value = conn ? (conn.password || '') : '';
$('rdpConnEditId').value = conn ? conn.id : '';
$('rdpConnSubmit').textContent = isEdit ? 'Save Changes' : 'Save Connection';
const type = conn ? conn.type : 'vnc';
document.querySelector('input[name="rdpProtocol"][value="' + type + '"]').checked = true;
updateRdpProtocolUI(type);
openModal('modal-addRdp');
}
function updateRdpProtocolUI(type) {
const portEl = $('rdpConnPort');
const usernameGroup = $('rdpUsernameGroup');
if (type === 'rdp') {
if (portEl && portEl.value === '5900') portEl.value = '3389';
if (usernameGroup) usernameGroup.style.display = '';
} else {
if (portEl && portEl.value === '3389') portEl.value = '5900';
if (usernameGroup) usernameGroup.style.display = 'none';
}
}
// ── VNC viewer (noVNC RFB) ────────────────────────────────────────────────────
function initVncViewer(wsPort, conn) {
const container = $('rdpViewerContainer');
if (!container) return null;
container.innerHTML = '';
// noVNC is loaded as an ES module that sets window.RFB
const RFB = window.RFB;
if (!RFB) {
container.innerHTML = 'noVNC (RFB) not loaded. Check vendor/novnc.js.
';
return null;
}
let rfb;
try {
rfb = new RFB(container, 'ws://127.0.0.1:' + wsPort, {
credentials: conn.password ? { password: conn.password } : undefined
});
rfb.scaleViewport = true;
rfb.resizeSession = false;
rfb.viewOnly = false;
rfb.clipViewport = false;
rfb.dragViewport = false;
rfb.focusOnClick = true;
rfb.background = '#000';
} catch (e) {
container.innerHTML = 'Failed to init noVNC: ' + escapeHtml(e.message) + '
';
return null;
}
rfb.addEventListener('connect', () => {
$('rdpStatusDot').className = 'terminal-status-dot';
$('rdpStateDisplay').textContent = 'Connected';
$('rdpStateDisplay').style.color = 'var(--green)';
const w = rfb._fbWidth || conn.width || '?';
const h = rfb._fbHeight || conn.height || '?';
$('rdpResDisplay').textContent = w + '×' + h;
});
rfb.addEventListener('disconnect', (e) => {
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
$('rdpStateDisplay').textContent = e.detail && e.detail.clean ? 'Disconnected' : 'Connection lost';
$('rdpStateDisplay').style.color = 'var(--text3)';
});
rfb.addEventListener('desktopname', (e) => {
if (e.detail && e.detail.name) $('rdpViewerInfo').textContent = e.detail.name;
});
rfb.addEventListener('credentialsrequired', () => {
const pw = prompt('VNC password required:');
if (pw !== null) rfb.sendCredentials({ password: pw });
});
return rfb;
}
// ── RDP viewer (node-rdpjs bitmap renderer) ───────────────────────────────────
function initRdpViewer(wsPort, conn) {
const container = $('rdpViewerContainer');
if (!container) return null;
container.innerHTML = '';
const canvas = document.createElement('canvas');
canvas.width = conn.width || 1280;
canvas.height = conn.height || 720;
canvas.style.display = 'block';
canvas.style.cursor = 'default';
container.appendChild(canvas);
const ctx = canvas.getContext('2d');
$('rdpResDisplay').textContent = canvas.width + '×' + canvas.height;
let ws;
try {
ws = new WebSocket('ws://127.0.0.1:' + wsPort);
} catch (e) {
container.innerHTML = 'WebSocket failed: ' + escapeHtml(e.message) + '
';
return null;
}
ws.onopen = () => {
$('rdpStateDisplay').textContent = 'Waiting for RDP…';
$('rdpStateDisplay').style.color = 'var(--amber)';
};
ws.onmessage = (event) => {
let msg;
try { msg = JSON.parse(event.data); } catch (_) { return; }
if (msg.type === 'connected') {
$('rdpStatusDot').className = 'terminal-status-dot';
$('rdpStateDisplay').textContent = 'Connected';
$('rdpStateDisplay').style.color = 'var(--green)';
if (msg.width && msg.height) {
canvas.width = msg.width;
canvas.height = msg.height;
$('rdpResDisplay').textContent = msg.width + '×' + msg.height;
}
} else if (msg.type === 'bitmap') {
renderRdpBitmap(ctx, msg);
} else if (msg.type === 'close') {
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
$('rdpStateDisplay').textContent = 'Disconnected';
$('rdpStateDisplay').style.color = 'var(--text3)';
} else if (msg.type === 'error') {
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
$('rdpStateDisplay').textContent = 'Error: ' + (msg.message || 'unknown');
$('rdpStateDisplay').style.color = 'var(--red)';
}
};
ws.onclose = () => {
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
$('rdpStateDisplay').textContent = 'Disconnected';
$('rdpStateDisplay').style.color = 'var(--text3)';
};
ws.onerror = () => {
$('rdpStateDisplay').textContent = 'WebSocket error';
$('rdpStateDisplay').style.color = 'var(--red)';
};
// Mouse events → WS
canvas.addEventListener('mousemove', (e) => {
if (ws.readyState !== WebSocket.OPEN) return;
const r = canvas.getBoundingClientRect();
const scaleX = canvas.width / r.width;
const scaleY = canvas.height / r.height;
ws.send(JSON.stringify({ type: 'mouseMove', x: Math.round((e.clientX - r.left) * scaleX), y: Math.round((e.clientY - r.top) * scaleY) }));
});
canvas.addEventListener('mousedown', (e) => {
if (ws.readyState !== WebSocket.OPEN) return;
const r = canvas.getBoundingClientRect();
const scaleX = canvas.width / r.width;
const scaleY = canvas.height / r.height;
const btn = e.button === 2 ? 2 : e.button === 1 ? 3 : 1;
ws.send(JSON.stringify({ type: 'mouseButton', x: Math.round((e.clientX - r.left) * scaleX), y: Math.round((e.clientY - r.top) * scaleY), button: btn, isDown: true }));
});
canvas.addEventListener('mouseup', (e) => {
if (ws.readyState !== WebSocket.OPEN) return;
const r = canvas.getBoundingClientRect();
const scaleX = canvas.width / r.width;
const scaleY = canvas.height / r.height;
const btn = e.button === 2 ? 2 : e.button === 1 ? 3 : 1;
ws.send(JSON.stringify({ type: 'mouseButton', x: Math.round((e.clientX - r.left) * scaleX), y: Math.round((e.clientY - r.top) * scaleY), button: btn, isDown: false }));
});
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Keyboard events → WS (unicode-based for broad compatibility)
canvas.setAttribute('tabindex', '0');
canvas.addEventListener('keydown', (e) => {
e.preventDefault();
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'keyUnicode', code: e.key.charCodeAt(0) || 0, isDown: true }));
});
canvas.addEventListener('keyup', (e) => {
e.preventDefault();
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'keyUnicode', code: e.key.charCodeAt(0) || 0, isDown: false }));
});
return ws;
}
function renderRdpBitmap(ctx, bitmap) {
const { destLeft, destTop, destRight, destBottom, width, height, bitsPerPixel, isCompress, data } = bitmap;
if (!data) return;
const raw = atob(data);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
const drawW = destRight - destLeft;
const drawH = destBottom - destTop;
if (drawW <= 0 || drawH <= 0) return;
// Convert raw bitmap bytes to RGBA ImageData
// node-rdpjs sends uncompressed data as raw RGB/BGR pixels
const imgData = ctx.createImageData(width, height);
const pixels = imgData.data;
if (bitsPerPixel === 32) {
for (let i = 0, p = 0; i < bytes.length && p < pixels.length; i += 4, p += 4) {
pixels[p] = bytes[i + 2]; // R (BGRA → RGBA)
pixels[p + 1] = bytes[i + 1]; // G
pixels[p + 2] = bytes[i]; // B
pixels[p + 3] = 255;
}
} else if (bitsPerPixel === 24) {
for (let i = 0, p = 0; i < bytes.length && p < pixels.length; i += 3, p += 4) {
pixels[p] = bytes[i + 2]; // R
pixels[p + 1] = bytes[i + 1]; // G
pixels[p + 2] = bytes[i]; // B
pixels[p + 3] = 255;
}
} else if (bitsPerPixel === 16) {
for (let i = 0, p = 0; i < bytes.length - 1 && p < pixels.length; i += 2, p += 4) {
const v = bytes[i] | (bytes[i + 1] << 8);
pixels[p] = ((v >> 11) & 0x1f) << 3;
pixels[p + 1] = ((v >> 5) & 0x3f) << 2;
pixels[p + 2] = (v & 0x1f) << 3;
pixels[p + 3] = 255;
}
} else {
return; // unsupported depth
}
// Draw bitmap to an offscreen canvas then blit to main canvas at dest coords
const offscreen = document.createElement('canvas');
offscreen.width = width;
offscreen.height = height;
offscreen.getContext('2d').putImageData(imgData, 0, 0);
ctx.drawImage(offscreen, destLeft, destTop, drawW, drawH);
}
async function connectRdp(conn) {
openModal('modal-rdpViewer');
const labelEl = $('rdpViewerLabel');
if (labelEl) labelEl.textContent = conn.label || (conn.type === 'rdp' ? 'RDP Desktop' : 'VNC Desktop');
const protoBadge = $('rdpProtocolBadge');
if (protoBadge) {
protoBadge.textContent = conn.type.toUpperCase();
protoBadge.className = 'badge ' + (conn.type === 'rdp' ? 'badge-amber' : 'badge-cyan');
}
$('rdpStatusDot').className = 'terminal-status-dot';
$('rdpStateDisplay').textContent = 'Connecting…';
$('rdpStateDisplay').style.color = 'var(--amber)';
$('rdpResDisplay').textContent = (conn.width || 1280) + '×' + (conn.height || 720);
$('rdpViewerInfo').textContent = '';
// Clean up any existing session
await disconnectRdp();
const result = await sendToNative('startRdpSession', {
type: conn.type,
hsUrl: conn.hsUrl,
port: conn.port,
username: conn.username || '',
password: conn.password || '',
domain: '',
width: conn.width || 1280,
height: conn.height || 720,
label: conn.label || ''
});
if (!result || !result.ok) {
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
$('rdpStateDisplay').textContent = 'Failed: ' + ((result && result.error) || 'Unknown error');
$('rdpStateDisplay').style.color = 'var(--red)';
return;
}
const { sessionId, wsPort } = result;
let viewer = null;
if (conn.type === 'vnc') {
viewer = initVncViewer(wsPort, conn);
activeRdpSession = { sessionId, wsPort, type: 'vnc', rfb: viewer, ws: null, conn };
} else {
viewer = initRdpViewer(wsPort, conn);
activeRdpSession = { sessionId, wsPort, type: 'rdp', rfb: null, ws: viewer, conn };
}
}
async function disconnectRdp() {
if (!activeRdpSession) return;
const { sessionId, rfb, ws } = activeRdpSession;
activeRdpSession = null;
if (rfb) { try { rfb.disconnect(); } catch (_) {} }
if (ws) { try { ws.close(); } catch (_) {} }
const container = $('rdpViewerContainer');
if (container) container.innerHTML = '';
if (sessionId) {
await sendToNative('stopRdpSession', { sessionId });
}
}
function setupRdpEvents() {
$('addRdpBtn')?.addEventListener('click', () => openAddRdpModal(null));
// Protocol radio change → update port default and username visibility
document.querySelectorAll('input[name="rdpProtocol"]').forEach(radio => {
radio.addEventListener('change', () => updateRdpProtocolUI(radio.value));
});
// Save connection
$('rdpConnSubmit')?.addEventListener('click', () => {
const label = $('rdpConnLabel').value.trim();
const hsUrl = $('rdpConnHsUrl').value.trim();
const type = document.querySelector('input[name="rdpProtocol"]:checked')?.value || 'vnc';
const port = parseInt($('rdpConnPort').value, 10) || (type === 'rdp' ? 3389 : 5900);
const width = parseInt($('rdpConnWidth').value, 10) || 1280;
const height = parseInt($('rdpConnHeight').value, 10) || 720;
const username = $('rdpConnUsername').value.trim();
const password = $('rdpConnPassword').value;
const editId = $('rdpConnEditId').value;
if (!hsUrl) { showModalError('modal-addRdp', 'rdpConnError', 'Holesail key is required'); return; }
if (!hsUrl.startsWith('hs://')) { showModalError('modal-addRdp', 'rdpConnError', 'Key must start with hs://'); return; }
const entry = { id: editId || generateRdpId(), label, hsUrl, type, port, width, height, username, password };
if (editId) {
const idx = rdpConnections.findIndex(c => c.id === editId);
if (idx !== -1) rdpConnections[idx] = entry;
} else {
rdpConnections.push(entry);
}
saveRdpConnections();
closeModal('modal-addRdp');
showToast(editId ? 'Connection updated' : 'Connection saved', 'success');
});
// Remove confirm
$('removeRdpConfirm')?.addEventListener('click', () => {
const id = $('removeRdpConfirm').dataset.rdpId;
rdpConnections = rdpConnections.filter(c => c.id !== id);
saveRdpConnections();
closeModal('modal-removeRdp');
showToast('Connection removed', 'success');
});
// Disconnect button
$('rdpDisconnectBtn')?.addEventListener('click', async () => {
await disconnectRdp();
closeModal('modal-rdpViewer');
});
// Fullscreen toggle
$('rdpFullscreenBtn')?.addEventListener('click', () => {
const modal = document.querySelector('#modal-rdpViewer .modal');
if (!modal) return;
if (modal.style.width === '100vw') {
modal.style.width = '';
modal.style.height = '';
modal.style.borderRadius = '';
} else {
modal.style.width = '100vw';
modal.style.height = '100vh';
modal.style.borderRadius = '0';
}
});
// Clean up session when viewer modal is closed via backdrop/escape
const viewerModal = $('modal-rdpViewer');
if (viewerModal) {
const observer = new MutationObserver(() => {
if (!viewerModal.classList.contains('open') && activeRdpSession) {
disconnectRdp();
}
});
observer.observe(viewerModal, { attributes: true, attributeFilter: ['class'] });
}
}
// ── Backups ──────────────────────────────────────────────────────────────────
let pendingRestoreFilename = null;
let pendingDeleteFilename = null;
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
function updateBackupsTable(backups) {
const tbody = $('backupsTable');
if (!tbody) return;
const countEl = $('backupCount');
if (countEl) countEl.textContent = backups ? backups.length : 0;
if (!backups || backups.length === 0) {
tbody.innerHTML = `
No backups yet
Click "Take Backup" to create your first backup
|
`;
return;
}
tbody.innerHTML = backups.map((b) => {
const name = escapeHtml(b.filename);
const created = b.createdAt ? timeAgo(b.createdAt) : '—';
const size = b.size ? formatBytes(b.size) : '—';
return `
| ${name} |
${created} |
${size} |
|
`;
}).join('');
}
function refreshBackups() {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'listBackups' } },
(response) => {
if (chrome.runtime.lastError) return;
if (response && response.ok) {
updateBackupsTable(response.backups || []);
}
}
);
}
function setupBackupEvents() {
// Take backup button
$('btnTakeBackup')?.addEventListener('click', () => {
const btn = $('btnTakeBackup');
if (btn) { btn.disabled = true; btn.textContent = 'Creating…'; }
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'createBackup' } },
(response) => {
if (btn) {
btn.disabled = false;
btn.innerHTML = ` Take Backup`;
}
if (response && response.ok) {
showToast('Backup created: ' + response.filename, 'success');
refreshBackups();
} else {
showToast(response?.error || 'Backup failed', 'error');
}
}
);
});
// Restore / Delete buttons (event delegation on the table)
$('backupsTable')?.addEventListener('click', (e) => {
const restoreBtn = e.target.closest('[data-backup-restore]');
if (restoreBtn) {
pendingRestoreFilename = restoreBtn.dataset.backupRestore;
const nameEl = $('restoreBackupName');
if (nameEl) nameEl.textContent = pendingRestoreFilename;
openModal('modal-restoreBackup');
return;
}
const deleteBtn = e.target.closest('[data-backup-delete]');
if (deleteBtn) {
pendingDeleteFilename = deleteBtn.dataset.backupDelete;
const nameEl = $('deleteBackupName');
if (nameEl) nameEl.textContent = pendingDeleteFilename;
openModal('modal-deleteBackup');
}
});
// Restore confirm
$('restoreBackupConfirm')?.addEventListener('click', () => {
if (!pendingRestoreFilename) return;
const btn = $('restoreBackupConfirm');
if (btn) btn.disabled = true;
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'restoreBackup', payload: { filename: pendingRestoreFilename } } },
(response) => {
if (btn) btn.disabled = false;
if (response && response.ok) {
closeModal('modal-restoreBackup');
const certsNote = response.restoredCerts ? ' Certificates restored.' : '';
showToast('Backup restored.' + certsNote + ' Restart tunnels to apply changes.', 'success');
pendingRestoreFilename = null;
refresh();
} else {
showModalError('modal-restoreBackup', 'restoreBackupError', response?.error || 'Restore failed');
}
}
);
});
// Delete confirm
$('deleteBackupConfirm')?.addEventListener('click', () => {
if (!pendingDeleteFilename) return;
const btn = $('deleteBackupConfirm');
if (btn) btn.disabled = true;
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'deleteBackup', payload: { filename: pendingDeleteFilename } } },
(response) => {
if (btn) btn.disabled = false;
if (response && response.ok) {
closeModal('modal-deleteBackup');
showToast('Backup deleted', 'success');
pendingDeleteFilename = null;
refreshBackups();
} else {
showModalError('modal-deleteBackup', 'deleteBackupError', response?.error || 'Delete failed');
}
}
);
});
}
// ── Fetch state ─────────────────────────────────────────────────────────────
async function fetchState() {
return new Promise((resolve) => {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'getState' },
(response) => {
if (chrome.runtime.lastError) {
log('fetchState error:', chrome.runtime.lastError.message);
resolve(null);
return;
}
if (response && response.ok) resolve(response.state);
else resolve(null);
}
);
});
}
// ── Overview infinite-scroll + debounced filter engine ───────────────────────
//
// Each list is managed by an OvList instance:
// - holds the full dataset and current filter string
// - renders PAGE_SIZE rows at a time into a
// - an IntersectionObserver on a sentinel appends the next page when
// the sentinel scrolls into view (infinite scroll)
// - a debounced input handler resets the list on every keystroke
const OV_PAGE = 20; // rows per page
class OvList {
constructor({ bodyId, sentinelId, searchId, subtitleId, rowFn, emptyMsg, cols }) {
this.body = $(bodyId);
this.sentinel = $(sentinelId);
this.searchEl = $(searchId);
this.subtitleEl = $(subtitleId);
this.rowFn = rowFn; // (item) => HTML string
this.emptyMsg = emptyMsg;
this.cols = cols; // colspan for empty row
this.data = []; // full unfiltered dataset
this.filtered = []; // after filter applied
this.rendered = 0; // rows currently in DOM
this._debounce = null;
this._observer = null;
this._initObserver();
this._initSearch();
}
_initObserver() {
if (!this.sentinel) return;
this._observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) this._appendPage();
}, { threshold: 0 });
this._observer.observe(this.sentinel);
}
_initSearch() {
if (!this.searchEl) return;
this.searchEl.addEventListener('input', () => {
clearTimeout(this._debounce);
this._debounce = setTimeout(() => this._applyFilter(), 180);
});
}
setData(data, subtitleText) {
this.data = data;
if (this.subtitleEl) this.subtitleEl.textContent = subtitleText;
this._applyFilter();
}
_applyFilter() {
const q = (this.searchEl?.value || '').trim().toLowerCase();
this.filtered = q
? this.data.filter(item => JSON.stringify(item).toLowerCase().includes(q))
: this.data.slice();
this._reset();
}
_reset() {
if (!this.body) return;
this.rendered = 0;
this.body.innerHTML = '';
if (this.filtered.length === 0) {
this.body.innerHTML = `
| ${this.emptyMsg} |
`;
return;
}
this._appendPage();
}
_appendPage() {
if (!this.body || this.rendered >= this.filtered.length) return;
const next = this.filtered.slice(this.rendered, this.rendered + OV_PAGE);
this.body.insertAdjacentHTML('beforeend', next.map(this.rowFn).join(''));
this.rendered += next.length;
}
}
// Instances — created once, reused on every updateDashboard call
let _ovVhost = null;
let _ovServers = null;
let _ovSvc = null;
let _ovSsh = null;
let _ovRdp = null;
function initOvLists() {
_ovVhost = new OvList({
bodyId: 'recentConnections', sentinelId: 'ovVhostSentinel',
searchId: 'ovVhostSearch', subtitleId: 'ovVhostSubtitle',
cols: 3, emptyMsg: 'No virtual hosts',
rowFn: v => {
const h = v.hostname || v.id || '';
return `
| ${escapeHtml(h)} |
${stateTag(v.state)} |
Open |
`;
}
});
_ovServers = new OvList({
bodyId: 'ovServersBody', sentinelId: 'ovServersSentinel',
searchId: 'ovServersSearch', subtitleId: 'ovServersSubtitle',
cols: 3, emptyMsg: 'No server tunnels',
rowFn: s => {
const key = s.hsUrl || '';
const short = key.length > 22 ? key.slice(0, 9) + '…' + key.slice(-7) : key;
return `
| ${escapeHtml(String(s.port || '—'))} |
${stateTag(s.state)} |
${escapeHtml(short)} |
`;
}
});
_ovSvc = new OvList({
bodyId: 'ovSvcBody', sentinelId: 'ovSvcSentinel',
searchId: 'ovSvcSearch', subtitleId: 'ovSvcSubtitle',
cols: 3, emptyMsg: 'No service tunnels',
rowFn: t => `
| ${escapeHtml(t.label || '—')} |
${escapeHtml(String(t.localPort || '—'))} |
${stateTag(t.state)} |
`
});
_ovSsh = new OvList({
bodyId: 'ovSshBody', sentinelId: 'ovSshSentinel',
searchId: 'ovSshSearch', subtitleId: 'ovSshSubtitle',
cols: 2, emptyMsg: 'No SSH connections',
rowFn: c => `
| ${escapeHtml(c.label || c.hsUrl || '—')} |
${escapeHtml(c.username || '—')} |
`
});
_ovRdp = new OvList({
bodyId: 'ovRdpBody', sentinelId: 'ovRdpSentinel',
searchId: 'ovRdpSearch', subtitleId: 'ovRdpSubtitle',
cols: 2, emptyMsg: 'No remote desktops',
rowFn: c => `
| ${escapeHtml(c.label || '—')} |
${escapeHtml((c.type || 'vnc').toUpperCase())} |
`
});
// Quick action buttons → open existing modals directly
$('qaAddVhost') ?.addEventListener('click', () => openModal('modal-addVhost'));
$('qaAddServer') ?.addEventListener('click', () => openModal('modal-startServer'));
$('qaAddSvc') ?.addEventListener('click', () => openModal('modal-addServiceTunnel'));
$('qaAddSsh') ?.addEventListener('click', () => openAddSshModal(null));
$('qaAddRdp') ?.addEventListener('click', () => openAddRdpModal(null));
$('qaInstallCA') ?.addEventListener('click', () => openModal('modal-installCA'));
}
// ── Dashboard / Overview ─────────────────────────────────────────────────────
function updateDashboard(state) {
currentState = state;
const servers = state.servers || [];
const virtualHosts = state.virtualHosts || [];
const serviceTunnels = state.serviceTunnels || [];
// Sidebar status
const dot = $('sidebarDot');
const statusText = $('sidebarStatus');
if (state.hostConnected) {
dot?.classList.add('connected');
dot?.classList.remove('disconnected');
if (statusText) statusText.textContent = 'Connected';
} else {
dot?.classList.remove('connected');
dot?.classList.add('disconnected');
if (statusText) statusText.textContent = 'Disconnected';
}
const setText = (id, val) => { const el = $(id); if (el) el.textContent = val; };
// ── Stat cards ────────────────────────────────────────────────────────────
setText('dashConnections', virtualHosts.length);
setText('dashSwarms', servers.length);
setText('dashServiceTunnels', serviceTunnels.length);
setText('dashSsh', sshConnections.length);
setText('dashRdp', rdpConnections.length);
setText('dashUptime', state.caInstalled ? 'Trusted' : 'Not trusted');
const caIcon = $('caStatIcon');
if (caIcon) {
caIcon.style.background = state.caInstalled ? 'var(--green-dim)' : 'var(--red-dim)';
caIcon.style.color = state.caInstalled ? 'var(--green)' : 'var(--red)';
}
// ── System status bar ─────────────────────────────────────────────────────
const ovHostDot = $('ovHostDot');
if (ovHostDot) {
ovHostDot.classList.toggle('connected', !!state.hostConnected);
ovHostDot.classList.toggle('disconnected', !state.hostConnected);
}
setText('ovHostLabel', state.hostConnected ? 'Connected' : 'Disconnected');
const ovCaDot = $('ovCaDot');
if (ovCaDot) {
ovCaDot.style.background = state.caInstalled ? 'var(--green)' : 'var(--red)';
ovCaDot.style.boxShadow = state.caInstalled ? '0 0 5px var(--green)' : 'none';
}
setText('ovCaLabel', state.caInstalled ? 'Installed & trusted' : 'Not installed');
setText('ovProxyLabel', state.proxyPort != null ? `port ${state.proxyPort}` : '—');
setText('ovConnectLabel', state.connectProxyPort != null ? `port ${state.connectProxyPort}` : '—');
const lu = $('overviewLastUpdated');
if (lu) lu.textContent = 'Updated ' + new Date().toLocaleTimeString();
// ── Nav badges ────────────────────────────────────────────────────────────
setText('swarmCount', servers.length);
setText('connCount', virtualHosts.length);
setText('serviceTunnelCount', serviceTunnels.length);
setText('sshCount', sshConnections.length);
setText('rdpCount', rdpConnections.length);
setText('tabCount', state.caInstalled ? 'CA ✓' : 'CA');
// ── Initialise list engines on first call ─────────────────────────────────
if (!_ovVhost) initOvLists();
// ── Feed data into each list (subtitle auto-computed) ─────────────────────
const sub = (n, unit, readyArr) => {
if (n === 0) return `No ${unit}s`;
const r = readyArr.filter(x => x.state === 'ready').length;
return `${n} ${unit}${n !== 1 ? 's' : ''} — ${r} ready`;
};
_ovVhost .setData(virtualHosts, sub(virtualHosts.length, 'host', virtualHosts));
_ovServers.setData(servers, sub(servers.length, 'tunnel', servers));
_ovSvc .setData(serviceTunnels, sub(serviceTunnels.length, 'tunnel', serviceTunnels));
_ovSsh .setData(sshConnections, sshConnections.length === 0
? 'No SSH connections' : `${sshConnections.length} saved`);
_ovRdp .setData(rdpConnections, rdpConnections.length === 0
? 'No remote desktops' : `${rdpConnections.length} saved`);
}
// ── Virtual Hosts table ──────────────────────────────────────────────────────
function updateConnectionsTable(state) {
const tbody = $('connectionsTable');
if (!tbody) return;
const virtualHosts = state.virtualHosts || [];
const proxyPort = state.proxyPort || 8443;
if (virtualHosts.length === 0) {
tbody.innerHTML = `
No virtual hosts
Click "Add Host" to assign a hostname to an hs:// tunnel
|
`;
return;
}
tbody.innerHTML = virtualHosts.map(v => {
const hostname = v.hostname || v.id || '';
const hsUrl = v.hsUrl || '';
const backend = (v.localHost && v.localPort != null) ? v.localHost + ':' + v.localPort : '—';
const openUrl = `https://${hostname}`;
const safeHostname = hostname.replace(/"/g, '"');
const needsReconnect = v.state === 'error' || v.state === 'closed';
return `
| ${escapeHtml(hostname)} |
${truncate(hsUrl, 28)}
|
${escapeHtml(backend)} |
${stateTag(v.state)} |
Open
${needsReconnect ? ` ` : ''}
|
`;
}).join('');
// Wire copy buttons
tbody.querySelectorAll('[data-copy]').forEach(btn => {
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
});
// Wire reconnect buttons
tbody.querySelectorAll('[data-reconnect-vhost]').forEach(btn => {
btn.addEventListener('click', () => {
const hostname = btn.dataset.reconnectVhost;
const hsUrl = btn.dataset.hsUrl;
btn.disabled = true;
btn.textContent = 'Reconnecting…';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname, hsUrl } } },
(response) => {
if (response?.ok) {
showToast('Tunnel reconnecting…', 'success');
} else {
showToast(response?.error || 'Reconnect failed', 'error');
}
refresh();
}
);
});
});
// Wire remove buttons
tbody.querySelectorAll('[data-remove-vhost]').forEach(btn => {
btn.addEventListener('click', () => {
const hostname = btn.dataset.removeVhost;
const nameEl = $('removeVhostName');
if (nameEl) nameEl.textContent = hostname;
$('removeVhostConfirm').dataset.hostname = hostname;
openModal('modal-removeVhost');
});
});
}
// ── Servers table ────────────────────────────────────────────────────────────
function updateSwarmsTable(state) {
const tbody = $('swarmsTable');
if (!tbody) return;
const servers = state.servers || [];
if (servers.length === 0) {
tbody.innerHTML = `
No server tunnels
Click "New Server" to expose a local port as an hs:// tunnel
|
`;
return;
}
tbody.innerHTML = servers.map(s => {
const id = s.id || s.serverId || '';
const url = s.url || s.hsUrl || '';
const safeId = id.replace(/"/g, '"');
return `
| ${escapeHtml(truncate(id, 20))} |
${s.port ?? '—'} |
${truncate(url, 30)}
${url ? ` ` : ''}
|
${s.udp ? 'UDP' : 'TCP'}${s.secure ? `secure` : `plain`}
|
|
`;
}).join('');
tbody.querySelectorAll('[data-copy]').forEach(btn => {
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
});
tbody.querySelectorAll('[data-edit-server]').forEach(btn => {
btn.addEventListener('click', () => {
const serverId = btn.dataset.editServer;
const server = (state.servers || []).find(s => (s.id || s.serverId) === serverId);
if (!server) return;
$('serverEditId').value = serverId;
const portEl = $('startServerPort');
const hostEl = $('startServerHost');
const secureEl = $('startServerSecure');
if (portEl) portEl.value = server.port ?? 3000;
if (hostEl) hostEl.value = server.host ?? '127.0.0.1';
if (secureEl) secureEl.checked = server.secure !== false;
const udpEl = document.querySelector('input[name="startServerProtocol"][value="' + (server.udp ? 'udp' : 'tcp') + '"]');
if (udpEl) udpEl.checked = true;
const titleEl = $('modal-startServer-title');
if (titleEl) titleEl.textContent = 'Edit Server Tunnel';
const submitEl = $('startServerSubmit');
if (submitEl) submitEl.textContent = 'Save Changes';
openModal('modal-startServer');
});
});
tbody.querySelectorAll('[data-stop-server]').forEach(btn => {
btn.addEventListener('click', () => {
const serverId = btn.dataset.stopServer;
const nameEl = $('stopServerName');
if (nameEl) nameEl.textContent = serverId;
$('stopServerConfirm').dataset.serverId = serverId;
openModal('modal-stopServer');
});
});
}
// ── Proxy & CA page ──────────────────────────────────────────────────────────
function updateTabsTable(state) {
const portEl = $('proxyInfoPort');
const caEl = $('proxyInfoCA');
if (portEl) portEl.textContent = state.proxyPort != null ? state.proxyPort : '—';
if (caEl) {
caEl.textContent = state.caInstalled ? 'Installed' : 'Not installed';
caEl.style.color = state.caInstalled ? 'var(--green)' : 'var(--red)';
}
// Show validator only when CA is installed
const validatorCard = $('certValidatorCard');
if (validatorCard) validatorCard.style.display = state.caInstalled ? '' : 'none';
if (state.caInstalled) {
renderValidatorTable(state.virtualHosts || []);
}
}
// ── Certificate Validator ─────────────────────────────────────────────────────
// Per-host test results: hostname -> { status, tlsOk, httpStatus, ms, error }
const validationResults = new Map();
function renderValidatorTable(virtualHosts) {
const tbody = $('certValidatorBody');
if (!tbody) return;
if (virtualHosts.length === 0) {
tbody.innerHTML = `
| No virtual hosts to test — add one in Virtual Hosts |
`;
return;
}
tbody.innerHTML = virtualHosts.map(v => {
const hostname = v.hostname || '';
const r = validationResults.get(hostname);
return `
| ${escapeHtml(hostname)} |
${renderTlsCell(hostname, r)} |
${renderStatusCell(hostname, r)} |
${renderTimeCell(hostname, r)} |
|
`;
}).join('');
tbody.querySelectorAll('[data-validate-host]').forEach(btn => {
btn.addEventListener('click', () => runValidation(btn.dataset.validateHost));
});
}
function renderTlsCell(hostname, r) {
if (!r) return `
—`;
if (r.status === 'running') return `
Testing…`;
if (r.tlsOk) return `
✓ Trusted`;
return `
✗ Untrusted`;
}
function renderStatusCell(hostname, r) {
if (!r || r.status === 'running') return `
—`;
if (!r.tlsOk) return `
—`;
if (r.httpStatus == null) return `
No response`;
const cls = r.httpStatus < 400 ? 'badge-green' : r.httpStatus < 500 ? 'badge-amber' : 'badge-red';
return `
${r.httpStatus}`;
}
function renderTimeCell(hostname, r) {
if (!r || r.status === 'running' || !r.tlsOk || r.ms == null) return `
—`;
const color = r.ms < 500 ? 'var(--green)' : r.ms < 2000 ? 'var(--amber)' : 'var(--red)';
return `
${r.ms}ms`;
}
function updateValidatorRow(hostname) {
const r = validationResults.get(hostname);
const row = document.getElementById('vrow-' + CSS.escape(hostname));
if (!row) return;
const cells = row.querySelectorAll('td');
if (cells[1]) cells[1].innerHTML = renderTlsCell(hostname, r);
if (cells[2]) cells[2].innerHTML = renderStatusCell(hostname, r);
if (cells[3]) cells[3].innerHTML = renderTimeCell(hostname, r);
const btn = document.getElementById('vbtn-' + CSS.escape(hostname));
if (btn) {
btn.disabled = r && r.status === 'running';
btn.innerHTML = (r && r.status === 'running')
? `
Testing…`
: `
Test`;
}
}
async function runValidation(hostname) {
validationResults.set(hostname, { status: 'running' });
updateValidatorRow(hostname);
const url = `https://${hostname}`;
const start = Date.now();
try {
// fetch() goes through the PAC proxy → HTTPS proxy → Holesail tunnel.
// If the CA is not trusted by the browser, this throws a TypeError (net::ERR_CERT_AUTHORITY_INVALID).
// mode: 'no-cors' avoids CORS errors from opaque responses — we only care about TLS + reachability.
const resp = await fetch(url, { mode: 'no-cors', cache: 'no-store', signal: AbortSignal.timeout(15000) });
const ms = Date.now() - start;
// 'opaque' response (no-cors) means TLS succeeded and server responded — status is 0 but that's expected
const httpStatus = resp.type === 'opaque' ? null : resp.status;
validationResults.set(hostname, { status: 'done', tlsOk: true, httpStatus, ms });
} catch (err) {
const ms = Date.now() - start;
const msg = err.message || String(err);
// Distinguish TLS failure from tunnel/network failure
const isTlsError = msg.includes('ERR_CERT') || msg.includes('certificate') || msg.includes('SSL') || msg.includes('CERT');
validationResults.set(hostname, { status: 'done', tlsOk: false, ms, error: msg, isTlsError });
}
updateValidatorRow(hostname);
}
async function runAllValidations(virtualHosts) {
if (!virtualHosts || virtualHosts.length === 0) return;
// Run sequentially to avoid hammering the proxy
for (const v of virtualHosts) {
await runValidation(v.hostname || '');
}
}
function setupCertValidator() {
$('runAllValidationsBtn')?.addEventListener('click', () => {
const state = currentState;
if (state) runAllValidations(state.virtualHosts || []);
});
}
// ── Service Tunnels table ─────────────────────────────────────────────────────
function updateServiceTunnelsTable(state) {
const tbody = $('serviceTunnelsTable');
if (!tbody) return;
const tunnels = state.serviceTunnels || [];
const countEl = $('serviceTunnelCount');
if (countEl) countEl.textContent = tunnels.length;
if (tunnels.length === 0) {
tbody.innerHTML = `
No service tunnels
Click "Add Tunnel" to connect a remote TCP/UDP service via Holesail
|
`;
return;
}
tbody.innerHTML = tunnels.map(t => {
const id = t.id || '';
const label = t.label || id;
const hsUrl = t.hsUrl || '';
const localPort = t.localPort != null ? t.localPort : '—';
const localAddr = t.localPort != null ? `127.0.0.1:${t.localPort}` : '—';
const safeId = id.replace(/"/g, '"');
return `
| ${escapeHtml(label)} |
${truncate(hsUrl, 28)}
${hsUrl ? ` ` : ''}
|
${escapeHtml(localAddr)}
${t.localPort != null ? ` ` : ''}
|
${stateTag(t.state)} |
${(t.state === 'error' || t.state === 'closed') ? `
` : ''}
|
`;
}).join('');
tbody.querySelectorAll('[data-copy]').forEach(btn => {
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
});
tbody.querySelectorAll('[data-reconnect-svc]').forEach(btn => {
btn.addEventListener('click', () => {
const tunnelId = btn.dataset.reconnectSvc;
const hsUrl = btn.dataset.hsurl;
const label = btn.dataset.label;
const localPort = parseInt(btn.dataset.localport, 10);
btn.disabled = true;
btn.textContent = 'Reconnecting…';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'updateServiceTunnel', payload: { tunnelId, hsUrl, label, localPort } } },
(response) => {
if (chrome.runtime.lastError) { showToast('Reconnect failed: ' + chrome.runtime.lastError.message, 'error'); return; }
if (response?.ok) {
showToast('Service tunnel reconnecting…', 'success');
} else {
showToast(response?.error || 'Reconnect failed', 'error');
}
refresh();
}
);
});
});
tbody.querySelectorAll('[data-edit-service-tunnel]').forEach(btn => {
btn.addEventListener('click', () => {
const tunnelId = btn.dataset.editServiceTunnel;
const tunnel = (state.serviceTunnels || []).find(t => t.id === tunnelId);
if (!tunnel) return;
$('serviceTunnelEditId').value = tunnelId;
$('serviceTunnelLabel').value = tunnel.label || '';
$('serviceTunnelHsUrl').value = tunnel.hsUrl || '';
$('serviceTunnelLocalPort').value = tunnel.localPort != null ? tunnel.localPort : '';
const titleEl = $('modal-addServiceTunnel-title');
if (titleEl) titleEl.textContent = 'Edit Service Tunnel';
const submitEl = $('serviceTunnelSubmit');
if (submitEl) submitEl.textContent = 'Save';
openModal('modal-addServiceTunnel');
});
});
tbody.querySelectorAll('[data-remove-service-tunnel]').forEach(btn => {
btn.addEventListener('click', () => {
const tunnelId = btn.dataset.removeServiceTunnel;
const tunnel = (state.serviceTunnels || []).find(t => t.id === tunnelId);
const nameEl = $('removeServiceTunnelName');
if (nameEl) nameEl.textContent = (tunnel && tunnel.label) || tunnelId;
$('removeServiceTunnelConfirm').dataset.tunnelId = tunnelId;
openModal('modal-removeServiceTunnel');
});
});
}
// ── Refresh ──────────────────────────────────────────────────────────────────
async function refresh() {
const state = await fetchState();
if (state) {
// Sync SSH connections from native host state — decode base64 password if present
if (Array.isArray(state.sshConnections)) {
sshConnections = state.sshConnections.map(c => {
const existing = sshConnections.find(e => e.id === c.id);
// Prefer in-memory password (user just typed it), then decode persisted base64
let password = (existing && existing.password) || '';
if (!password && c.passwordB64) {
try { password = decodeURIComponent(escape(atob(c.passwordB64))); } catch (_) {}
}
return { ...c, password };
});
renderSshGrid();
}
// Sync RDP connections from native host state — decode base64 password if present
if (Array.isArray(state.rdpConnections)) {
rdpConnections = state.rdpConnections.map(c => {
const existing = rdpConnections.find(e => e.id === c.id);
// Prefer in-memory password (user just typed it), then decode persisted base64
let password = (existing && existing.password) || '';
if (!password && c.passwordB64) {
try { password = decodeURIComponent(escape(atob(c.passwordB64))); } catch (_) {}
}
return { ...c, password };
});
renderRdpGrid();
}
// Sync settings from native host state
if (state.settings && typeof state.settings === 'object') {
settings = { ...SETTINGS_DEFAULTS, ...state.settings };
}
updateDashboard(state);
updateConnectionsTable(state);
updateSwarmsTable(state);
updateTabsTable(state);
updateServiceTunnelsTable(state);
updateSettingsUI();
}
const sshCountEl = $('sshCount');
if (sshCountEl) sshCountEl.textContent = sshConnections.length;
refreshBackups();
}
// ── Events ───────────────────────────────────────────────────────────────────
function setupEvents() {
// FAB refresh
// Toggle switches
document.querySelectorAll('.toggle').forEach(toggle => {
toggle.addEventListener('click', () => toggle.classList.toggle('active'));
});
// Save settings
$('btnSaveSettings')?.addEventListener('click', saveSettings);
// Reset settings to defaults
$('btnResetSettings')?.addEventListener('click', () => {
settings = { ...SETTINGS_DEFAULTS };
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'updateSettings', payload: { ...settings } } },
(response) => {
if (response && response.settings) settings = { ...SETTINGS_DEFAULTS, ...response.settings };
updateSettingsUI();
showToast('Settings reset to defaults', 'success');
}
);
});
// ── Add Virtual Host ────────────────────────────────────────────────────
$('addVhostBtn')?.addEventListener('click', () => openModal('modal-addVhost'));
$('addVhostSubmit')?.addEventListener('click', () => {
const hostnameEl = $('addVhostHostname');
const hsUrlEl = $('addVhostHsUrl');
// Sanitize: strip protocol, port, path, trailing slashes
let hostname = (hostnameEl?.value || '').trim();
hostname = hostname.replace(/^https?:\/\//i, '').replace(/[/:?#].*$/, '').toLowerCase().trim();
const hsUrl = (hsUrlEl?.value || '').trim();
const hostnameValidation = isValidVhostHostname(hostname);
if (!hostnameValidation.ok) { showModalError('modal-addVhost', 'addVhostError', hostnameValidation.error); return; }
if (!hsUrl || !hsUrl.startsWith('hs://')) { showModalError('modal-addVhost', 'addVhostError', 'Enter a valid hs:// URL'); return; }
const btn = $('addVhostSubmit');
if (btn) { btn.disabled = true; btn.textContent = 'Adding…'; }
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname, hsUrl } } },
(response) => {
if (btn) { btn.disabled = false; btn.textContent = 'Add Host'; }
if (response?.ok) {
if (hostnameEl) hostnameEl.value = '';
if (hsUrlEl) hsUrlEl.value = '';
closeModal('modal-addVhost');
showToast('Virtual host added', 'success');
refresh();
} else {
showModalError('modal-addVhost', 'addVhostError', response?.error || 'Failed to add');
}
}
);
});
// ── Remove Virtual Host ─────────────────────────────────────────────────
$('removeVhostConfirm')?.addEventListener('click', () => {
const hostname = $('removeVhostConfirm').dataset.hostname;
if (!hostname) return;
const btn = $('removeVhostConfirm');
btn.disabled = true; btn.textContent = 'Removing…';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'removeVirtualHost', payload: { hostname } } },
(response) => {
btn.disabled = false; btn.textContent = 'Remove';
closeModal('modal-removeVhost');
if (response?.ok) {
showToast('Virtual host removed', 'success');
} else {
showToast(response?.error || 'Failed to remove', 'error');
}
refresh();
}
);
});
// ── Start Server ────────────────────────────────────────────────────────
$('startServerBtn')?.addEventListener('click', () => {
// Reset to "new server" mode
const editIdEl = $('serverEditId');
if (editIdEl) editIdEl.value = '';
const titleEl = $('modal-startServer-title');
if (titleEl) titleEl.textContent = 'Start Server Tunnel';
const submitEl = $('startServerSubmit');
if (submitEl) submitEl.textContent = 'Start Server';
const tcpEl = $('startServerProtocolTcp');
if (tcpEl) tcpEl.checked = true;
openModal('modal-startServer');
});
$('startServerSubmit')?.addEventListener('click', () => {
const portEl = $('startServerPort');
const hostEl = $('startServerHost');
const secureEl = $('startServerSecure');
const port = parseInt(portEl?.value, 10) || 3000;
const host = (hostEl?.value || '127.0.0.1').trim();
const secure = secureEl?.checked !== false;
const udp = document.querySelector('input[name="startServerProtocol"]:checked')?.value === 'udp';
const editId = ($('serverEditId')?.value || '').trim();
if (!port || port < 1 || port > 65535) { showModalError('modal-startServer', 'startServerError', 'Port must be 1–65535'); return; }
const btn = $('startServerSubmit');
if (btn) { btn.disabled = true; btn.textContent = editId ? 'Saving…' : 'Starting…'; }
const doStart = () => {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'startServer', payload: { port, host, secure, udp } } },
(response) => {
if (btn) { btn.disabled = false; btn.textContent = editId ? 'Save Changes' : 'Start Server'; }
if (response?.ok) {
if (editIdEl) editIdEl.value = '';
closeModal('modal-startServer');
showToast(editId ? 'Server updated' : 'Server started', 'success');
refresh();
} else {
showModalError('modal-startServer', 'startServerError', response?.error || 'Failed to start');
}
}
);
};
if (editId) {
// Stop old server first, then start new one with updated settings
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServer', payload: { serverId: editId } } },
() => doStart()
);
} else {
doStart();
}
});
// ── Stop Server ─────────────────────────────────────────────────────────
$('stopServerConfirm')?.addEventListener('click', () => {
const serverId = $('stopServerConfirm').dataset.serverId;
if (!serverId) return;
const btn = $('stopServerConfirm');
btn.disabled = true; btn.textContent = 'Stopping…';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServer', payload: { serverId } } },
(response) => {
btn.disabled = false; btn.textContent = 'Stop Server';
closeModal('modal-stopServer');
if (response?.ok) showToast('Server stopped', 'success');
else showToast(response?.error || 'Failed to stop', 'error');
refresh();
}
);
});
// ── Install CA ──────────────────────────────────────────────────────────
$('installCaBtn')?.addEventListener('click', () => openModal('modal-installCA'));
$('installCaSubmit')?.addEventListener('click', () => {
const btn = $('installCaSubmit');
const errEl = $('installCaError');
const successEl = $('installCaSuccess');
if (btn) { btn.disabled = true; btn.textContent = 'Installing…'; }
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
if (successEl) { successEl.style.display = 'none'; }
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'installRootCA', payload: {} } },
(response) => {
if (btn) { btn.disabled = false; btn.textContent = 'Install CA'; }
if (response?.ok) {
if (successEl) { successEl.textContent = '✓ Root CA installed. Fully quit and reopen Chrome (Cmd+Q) to apply trust.'; successEl.style.display = 'block'; }
showToast('Root CA installed', 'success');
setTimeout(() => closeModal('modal-installCA'), 2000);
refresh();
} else {
if (errEl) { errEl.textContent = response?.error || 'Installation failed'; errEl.style.display = 'block'; }
}
}
);
});
// ── Logs ────────────────────────────────────────────────────────────────
let logs = [];
let autoScroll = true;
let logFilter = '';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'registerDashboard' },
(response) => {
if (response && response.logs) { logs = response.logs; updateLogsDisplay(); }
}
);
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'holesail-logs' && message.logs) {
logs = message.logs;
updateLogsDisplay();
}
});
function getLogClass(msg) {
const m = (msg || '').toLowerCase();
if (m.includes('error') || m.includes('fail') || m.includes('err:')) return 'is-error';
if (m.includes('warn') || m.includes('warning')) return 'is-warn';
return '';
}
function updateLogsDisplay() {
const container = $('logsContainer');
if (!container) return;
const filtered = logFilter
? logs.filter(e => (e.message || '').toLowerCase().includes(logFilter.toLowerCase()))
: logs;
if (filtered.length === 0) {
container.innerHTML = `
${logFilter ? 'No matching logs' : 'No logs yet'}
${logFilter ? 'Try a different filter' : 'Logs will appear here as the extension runs'}
`;
return;
}
container.innerHTML = filtered.map(entry => {
const time = new Date(entry.timestamp).toLocaleTimeString();
const cls = getLogClass(entry.message);
return `
${time}
${escapeHtml(entry.message)}
`;
}).join('');
if (autoScroll) container.scrollTop = container.scrollHeight;
}
$('btnClearLogs')?.addEventListener('click', () => { logs = []; updateLogsDisplay(); });
$('btnAutoScroll')?.addEventListener('click', () => {
autoScroll = !autoScroll;
const btn = $('btnAutoScroll');
if (btn) {
const svgPart = btn.querySelector('svg')?.outerHTML || '';
btn.innerHTML = svgPart + ' Auto-scroll: ' + (autoScroll ? 'ON' : 'OFF');
}
});
$('logsFilter')?.addEventListener('input', (e) => {
logFilter = e.target.value;
updateLogsDisplay();
});
window.addEventListener('beforeunload', () => {
chrome.runtime.sendMessage({ target: 'holesail-native', action: 'unregisterDashboard' }, () => {});
});
// ── Service Tunnels ──────────────────────────────────────────────────────
$('addServiceTunnelBtn')?.addEventListener('click', () => {
$('serviceTunnelEditId').value = '';
$('serviceTunnelLabel').value = '';
$('serviceTunnelHsUrl').value = '';
$('serviceTunnelLocalPort').value = '';
const titleEl = $('modal-addServiceTunnel-title');
if (titleEl) titleEl.textContent = 'Add Service Tunnel';
const submitEl = $('serviceTunnelSubmit');
if (submitEl) submitEl.textContent = 'Connect';
openModal('modal-addServiceTunnel');
});
$('serviceTunnelSubmit')?.addEventListener('click', () => {
const label = ($('serviceTunnelLabel')?.value || '').trim();
const hsUrl = ($('serviceTunnelHsUrl')?.value || '').trim();
const localPort = parseInt($('serviceTunnelLocalPort')?.value, 10);
const editId = ($('serviceTunnelEditId')?.value || '').trim();
if (!label) { showModalError('modal-addServiceTunnel', 'serviceTunnelError', 'Label is required'); return; }
if (!hsUrl || !hsUrl.startsWith('hs://')) { showModalError('modal-addServiceTunnel', 'serviceTunnelError', 'Enter a valid hs:// key'); return; }
if (!localPort || localPort < 1 || localPort > 65535) { showModalError('modal-addServiceTunnel', 'serviceTunnelError', 'Local port must be 1–65535'); return; }
const btn = $('serviceTunnelSubmit');
if (btn) { btn.disabled = true; btn.textContent = 'Connecting…'; }
const type = editId ? 'updateServiceTunnel' : 'startServiceTunnel';
const payload = editId ? { tunnelId: editId, label, hsUrl, localPort } : { label, hsUrl, localPort };
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type, payload } },
(response) => {
if (btn) { btn.disabled = false; btn.textContent = editId ? 'Save' : 'Connect'; }
if (response?.ok) {
closeModal('modal-addServiceTunnel');
showToast(editId ? 'Tunnel updated' : 'Service tunnel connected', 'success');
refresh();
} else {
showModalError('modal-addServiceTunnel', 'serviceTunnelError', response?.error || 'Failed to connect');
}
}
);
});
$('removeServiceTunnelConfirm')?.addEventListener('click', () => {
const tunnelId = $('removeServiceTunnelConfirm').dataset.tunnelId;
if (!tunnelId) return;
const btn = $('removeServiceTunnelConfirm');
btn.disabled = true; btn.textContent = 'Removing…';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServiceTunnel', payload: { tunnelId } } },
(response) => {
btn.disabled = false; btn.textContent = 'Remove';
closeModal('modal-removeServiceTunnel');
if (response?.ok) showToast('Service tunnel removed', 'success');
else showToast(response?.error || 'Failed to remove', 'error');
refresh();
}
);
});
}
// ── SSH Connections ───────────────────────────────────────────────────────────
let sshConnections = []; // saved connections from native host state.json
let activeSshSession = null; // { sessionId, wsPort, term, fitAddon, ws, resizeObserver, conn }
function generateSshId() {
return 'ssh-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7);
}
function loadSshConnections(cb) {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'getSshConnections' } },
(response) => {
if (response && response.ok && Array.isArray(response.sshConnections)) {
sshConnections = response.sshConnections.map(c => {
let password = '';
if (c.passwordB64) {
try { password = decodeURIComponent(escape(atob(c.passwordB64))); } catch (_) {}
}
return { ...c, password };
});
}
if (cb) cb(sshConnections);
}
);
}
function saveSshConnections(cb) {
// Encode password as base64 before persisting so it survives page reloads
const toSave = sshConnections.map(c => {
const { password, ...rest } = c; // eslint-disable-line no-unused-vars
if (password) rest.passwordB64 = btoa(unescape(encodeURIComponent(password)));
else delete rest.passwordB64;
return rest;
});
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'setSshConnections', payload: { connections: toSave } } },
(response) => {
if (response && !response.ok) {
log('saveSshConnections failed:', response.error);
}
renderSshGrid();
const countEl = $('sshCount');
if (countEl) countEl.textContent = sshConnections.length;
if (cb) cb();
}
);
}
function renderSshGrid() {
const grid = $('sshGrid');
if (!grid) return;
if (sshConnections.length === 0) {
grid.innerHTML = `
No SSH connections
Add a connection to get started. You'll need an hs:// key for the remote peer.
`;
return;
}
grid.innerHTML = sshConnections.map(conn => `
${escapeHtml(conn.label || conn.username + '@ssh')}
`).join('');
// Wire up card buttons
grid.querySelectorAll('[data-ssh-connect]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const conn = sshConnections.find(c => c.id === btn.dataset.sshConnect);
if (conn) connectSsh(conn);
});
});
grid.querySelectorAll('[data-ssh-edit]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const conn = sshConnections.find(c => c.id === btn.dataset.sshEdit);
if (conn) openAddSshModal(conn);
});
});
grid.querySelectorAll('[data-ssh-remove]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const conn = sshConnections.find(c => c.id === btn.dataset.sshRemove);
if (conn) {
$('removeSshName').textContent = conn.label || conn.username;
$('removeSshConfirm').dataset.sshId = conn.id;
openModal('modal-removeSsh');
}
});
});
}
function openAddSshModal(conn) {
const isEdit = !!conn;
$('modal-addSsh-title').textContent = isEdit ? 'Edit SSH Connection' : 'Add SSH Connection';
$('sshConnLabel').value = conn ? conn.label : '';
$('sshConnHsUrl').value = conn ? conn.hsUrl : '';
$('sshConnUsername').value = conn ? conn.username : '';
$('sshConnPassword').value = conn ? (conn.password || '') : '';
$('sshConnEditId').value = conn ? conn.id : '';
$('sshConnSubmit').textContent = isEdit ? 'Save Changes' : 'Save Connection';
openModal('modal-addSsh');
}
function sendToNative(type, payload) {
return new Promise((resolve) => {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type, payload } },
(response) => resolve(response)
);
});
}
async function connectSsh(conn) {
// Show terminal modal immediately with a connecting state
openModal('modal-sshTerminal');
$('termConnLabel').textContent = conn.label || conn.username;
$('termUserHost').textContent = conn.username + '@ssh';
$('termStatusDot').className = 'terminal-status-dot';
$('termStateDisplay').textContent = 'Connecting…';
$('termStateDisplay').style.color = 'var(--amber)';
// Clean up any existing session
await disconnectSsh();
// Initialize xterm.js
const term = new Terminal({
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
fontSize: 13,
lineHeight: 1.3,
cursorBlink: true,
cursorStyle: 'block',
scrollback: 5000,
theme: {
background: '#0d0d0f',
foreground: '#e4e4e7',
cursor: '#22d3ee',
cursorAccent: '#0d0d0f',
selectionBackground: 'rgba(34,211,238,0.25)',
black: '#18181b',
red: '#f43f5e',
green: '#4ade80',
yellow: '#fbbf24',
blue: '#60a5fa',
magenta: '#c084fc',
cyan: '#22d3ee',
white: '#e4e4e7',
brightBlack: '#3f3f46',
brightRed: '#fb7185',
brightGreen: '#86efac',
brightYellow: '#fde68a',
brightBlue: '#93c5fd',
brightMagenta: '#d8b4fe',
brightCyan: '#67e8f9',
brightWhite: '#fafafa'
}
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
const container = $('terminalContainer');
container.innerHTML = '';
term.open(container);
// Fit synchronously now that the container is in the DOM, then wait a frame
// for the browser to finish layout so dimensions are accurate before we
// send cols/rows to the native host.
await new Promise(resolve => requestAnimationFrame(() => {
try { fitAddon.fit(); } catch (_) {}
updateTermSizeDisplay(term);
resolve();
}));
term.writeln('\x1b[36mConnecting to ' + escapeHtml(conn.label || conn.username) + '…\x1b[0m');
term.writeln('\x1b[90mEstablishing Holesail tunnel…\x1b[0m');
// Request native host to start SSH session
const cols = term.cols || 80;
const rows = term.rows || 24;
const result = await sendToNative('startSshSession', {
hsUrl: conn.hsUrl,
username: conn.username,
password: conn.password || '',
cols,
rows,
label: conn.label || conn.username
});
if (!result || !result.ok) {
const errMsg = (result && result.error) || 'Unknown error';
term.writeln('\x1b[31mFailed to start session: ' + errMsg + '\x1b[0m');
$('termStatusDot').className = 'terminal-status-dot disconnected';
$('termStateDisplay').textContent = 'Error';
$('termStateDisplay').style.color = 'var(--red)';
activeSshSession = { term, fitAddon, ws: null, resizeObserver: null, conn, sessionId: null };
return;
}
const { sessionId, wsPort } = result;
term.writeln('\x1b[90mTunnel ready — connecting SSH…\x1b[0m');
// Connect WebSocket to the WS bridge
let ws;
try {
ws = new WebSocket('ws://127.0.0.1:' + wsPort);
ws.binaryType = 'arraybuffer';
} catch (e) {
term.writeln('\x1b[31mWebSocket connection failed: ' + e.message + '\x1b[0m');
sendToNative('stopSshSession', { sessionId });
return;
}
ws.onopen = () => {
$('termStatusDot').className = 'terminal-status-dot';
$('termStateDisplay').textContent = 'Connected';
$('termStateDisplay').style.color = 'var(--green)';
// Send a ready-signal so the native host knows the browser WebSocket is
// fully open and can safely flush buffered PTY output (MOTD, prompt).
ws.send('\x00');
term.focus();
};
let firstMessage = true;
ws.onmessage = (event) => {
const data = event.data instanceof ArrayBuffer
? new Uint8Array(event.data)
: event.data;
if (firstMessage) {
firstMessage = false;
// Prepend ESC[2J (clear screen) + ESC[H (cursor home) to the first SSH
// data chunk so the clear and the MOTD are written atomically in the
// same xterm.js render pass — avoids the race where term.clear() wipes
// data that was already queued by term.write().
const CLEAR_HOME = '\x1b[2J\x1b[H';
if (typeof data === 'string') {
term.write(CLEAR_HOME + data);
} else {
const prefix = new TextEncoder().encode(CLEAR_HOME);
const combined = new Uint8Array(prefix.length + data.length);
combined.set(prefix);
combined.set(data, prefix.length);
term.write(combined);
}
return;
}
term.write(data);
};
ws.onclose = () => {
$('termStatusDot').className = 'terminal-status-dot disconnected';
$('termStateDisplay').textContent = 'Disconnected';
$('termStateDisplay').style.color = 'var(--text3)';
term.writeln('\r\n\x1b[90m[Session closed]\x1b[0m');
};
ws.onerror = () => {
term.writeln('\r\n\x1b[31m[WebSocket error]\x1b[0m');
};
// Terminal input → WebSocket
const dataDisposable = term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// Resize observer — refit on container resize, debounced so we don't
// flood the native host with stty commands during a window drag.
let resizeTimer = null;
const resizeObserver = new ResizeObserver(() => {
try { fitAddon.fit(); updateTermSizeDisplay(term); } catch (_) {}
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
try {
sendToNative('resizeSshSession', { sessionId, cols: term.cols, rows: term.rows });
} catch (_) {}
}, 150);
});
resizeObserver.observe(container);
activeSshSession = { sessionId, wsPort, term, fitAddon, ws, resizeObserver, conn, dataDisposable };
}
function updateTermSizeDisplay(term) {
const el = $('termSizeDisplay');
if (el && term) el.textContent = term.cols + '×' + term.rows;
}
async function disconnectSsh() {
if (!activeSshSession) return;
const { sessionId, ws, term, fitAddon, resizeObserver, dataDisposable } = activeSshSession;
activeSshSession = null;
if (resizeObserver) resizeObserver.disconnect();
if (dataDisposable) dataDisposable.dispose();
if (ws) { try { ws.close(); } catch (_) {} }
if (term) { try { term.dispose(); } catch (_) {} }
if (sessionId) {
await sendToNative('stopSshSession', { sessionId });
}
}
function setupSshEvents() {
// Add connection button
$('addSshBtn')?.addEventListener('click', () => openAddSshModal(null));
// Save / update connection
$('sshConnSubmit')?.addEventListener('click', () => {
const label = $('sshConnLabel').value.trim();
const hsUrl = $('sshConnHsUrl').value.trim();
const username = $('sshConnUsername').value.trim();
const password = $('sshConnPassword').value;
const editId = $('sshConnEditId').value;
if (!hsUrl) { showModalError('modal-addSsh', 'sshConnError', 'Holesail key is required'); return; }
if (!username) { showModalError('modal-addSsh', 'sshConnError', 'Username is required'); return; }
if (!hsUrl.startsWith('hs://')) { showModalError('modal-addSsh', 'sshConnError', 'Key must start with hs://'); return; }
if (editId) {
const idx = sshConnections.findIndex(c => c.id === editId);
if (idx !== -1) {
sshConnections[idx] = { ...sshConnections[idx], label, hsUrl, username, password };
}
} else {
sshConnections.push({ id: generateSshId(), label, hsUrl, username, password });
}
saveSshConnections();
closeModal('modal-addSsh');
showToast(editId ? 'Connection updated' : 'Connection saved', 'success');
});
// Remove connection confirm
$('removeSshConfirm')?.addEventListener('click', () => {
const id = $('removeSshConfirm').dataset.sshId;
sshConnections = sshConnections.filter(c => c.id !== id);
saveSshConnections();
closeModal('modal-removeSsh');
showToast('Connection removed', 'success');
});
// Terminal disconnect button
$('termDisconnectBtn')?.addEventListener('click', async () => {
await disconnectSsh();
closeModal('modal-sshTerminal');
});
// Terminal copy selection button
$('termCopyBtn')?.addEventListener('click', () => {
if (activeSshSession && activeSshSession.term) {
const sel = activeSshSession.term.getSelection();
if (sel) copyToClipboard(sel, null);
else showToast('No text selected', 'default');
}
});
// Fullscreen toggle
$('termFullscreenBtn')?.addEventListener('click', () => {
const modal = document.querySelector('#modal-sshTerminal .modal');
if (!modal) return;
if (modal.style.width === '100vw') {
modal.style.width = '';
modal.style.height = '';
modal.style.borderRadius = '';
} else {
modal.style.width = '100vw';
modal.style.height = '100vh';
modal.style.borderRadius = '0';
}
setTimeout(() => {
if (activeSshSession && activeSshSession.fitAddon) {
activeSshSession.fitAddon.fit();
updateTermSizeDisplay(activeSshSession.term);
}
}, 50);
});
// Clean up session when terminal modal is closed via backdrop/escape
const termModal = $('modal-sshTerminal');
if (termModal) {
const observer = new MutationObserver(() => {
if (!termModal.classList.contains('open') && activeSshSession) {
disconnectSsh();
}
});
observer.observe(termModal, { attributes: true, attributeFilter: ['class'] });
}
}
// ── Init ─────────────────────────────────────────────────────────────────────
async function init() {
log('Dashboard initializing…');
// Set dynamic version from manifest
try {
const manifest = chrome.runtime.getManifest();
const versionEl = $('sidebarVersion');
if (versionEl && manifest.version) {
versionEl.textContent = 'v' + manifest.version + ' · hole.sail';
}
} catch (_) {}
setupNavigation();
setupEvents();
setupCertValidator();
setupSshEvents();
setupRdpEvents();
setupBackupEvents();
// refresh() fetches state from native host which includes settings + sshConnections
await refresh();
setInterval(refresh, 2000);
}
init();