/** * 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.url || s.hsUrl || ''; const short = key.length > 22 ? key.slice(0, 9) + '…' + key.slice(-7) : key; const display = s.label ? escapeHtml(s.label) : `${escapeHtml(String(s.port || '—'))}`; return ` ${display} ${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', () => { const editIdEl = $('serverEditId'); if (editIdEl) editIdEl.value = ''; const labelEl = $('startServerLabel'); if (labelEl) labelEl.value = ''; const portEl = $('startServerPort'); if (portEl) portEl.value = '3000'; const hostEl = $('startServerHost'); if (hostEl) hostEl.value = '127.0.0.1'; const secureEl = $('startServerSecure'); if (secureEl) secureEl.checked = true; const tcpEl = $('startServerProtocolTcp'); if (tcpEl) tcpEl.checked = true; const titleEl = $('modal-startServer-title'); if (titleEl) titleEl.textContent = 'Start Server Tunnel'; const submitEl = $('startServerSubmit'); if (submitEl) submitEl.textContent = 'Start Server'; 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, '"'); const label = s.label || ''; return ` ${label ? `
${escapeHtml(label)}
` : ''}
${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 labelEl = $('startServerLabel'); const portEl = $('startServerPort'); const hostEl = $('startServerHost'); const secureEl = $('startServerSecure'); if (labelEl) labelEl.value = server.label || ''; 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', () => { const editIdEl = $('serverEditId'); if (editIdEl) editIdEl.value = ''; const labelEl = $('startServerLabel'); if (labelEl) labelEl.value = ''; const portEl = $('startServerPort'); if (portEl) portEl.value = '3000'; const hostEl = $('startServerHost'); if (hostEl) hostEl.value = '127.0.0.1'; const secureEl = $('startServerSecure'); if (secureEl) secureEl.checked = true; const tcpEl = $('startServerProtocolTcp'); if (tcpEl) tcpEl.checked = true; const titleEl = $('modal-startServer-title'); if (titleEl) titleEl.textContent = 'Start Server Tunnel'; const submitEl = $('startServerSubmit'); if (submitEl) submitEl.textContent = 'Start Server'; openModal('modal-startServer'); }); $('startServerSubmit')?.addEventListener('click', () => { const portEl = $('startServerPort'); const hostEl = $('startServerHost'); const secureEl = $('startServerSecure'); const labelEl = $('startServerLabel'); const editIdEl = $('serverEditId'); 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 label = (labelEl?.value || '').trim(); const editId = (editIdEl?.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, label } } }, (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) { 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();