Files
holesail-browser/extension/dashboard.js
T
Raven Scott 00d9c958ca
CI / Build & Test (push) Successful in 3m15s
feat: custom TLD virtual hosts with SNI certs, dynamic PAC, and TLD validation
- Add isValidVhostHostname() to dashboard.js with embedded REAL_TLDS and
  REAL_SLD_TLDS blocklists; enforces 3-label minimum (two-tier TLD requirement),
  valid label characters, and blocks real public TLDs/SLDs (e.g. .com, co.uk)
- Replace hardcoded .hole.sail validation in vhost submit handler with new validator
- Update Add Virtual Host modal hint text and add inline format explanation
- Update applyPAC() in background.js to accept a tlds array, generating one
  dnsDomainIs clause per unique two-label base domain; .hole.sail always included
- Store virtualHosts in extensionState and pass derived TLD list to applyPAC at
  every getState response and retryGetStateForConnectProxy call
- Replace single upfront *.hole.sail cert in https-proxy.js with SNICallback that
  lazily generates a wildcard cert per two-label base domain on first connection;
  baseline *.hole.sail cert still pre-generated at startup
- Add chrome.permissions.request() in background.js send handler to grant host
  permissions for new TLDs dynamically after successful setVirtualHost
- Add optional_host_permissions: ["*://*/*"] and "permissions" to manifest.json
  to enable runtime host permission grants for custom TLDs
2026-02-28 20:20:28 -05:00

2720 lines
119 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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) => `<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--${color});margin-right:5px;flex-shrink:0;${color === 'green' ? 'box-shadow:0 0 5px var(--green);' : ''}"></span>`;
if (!state || state === '—') return `<span class="badge badge-neutral">${dot('text4')}—</span>`;
if (state === 'ready') return `<span class="badge badge-green">${dot('green')}ready</span>`;
if (state === 'error') return `<span class="badge badge-red">${dot('red')}error</span>`;
if (state === 'closed') return `<span class="badge badge-neutral">${dot('text4')}closed</span>`;
if (state === 'connecting') return `<span class="badge badge-amber">${dot('amber')}connecting</span>`;
return `<span class="badge badge-amber">${dot('amber')}${escapeHtml(state)}</span>`;
}
// ── 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 = `
<div class="empty-state" style="grid-column:1/-1">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
<div class="empty-state-title">No remote desktop connections</div>
<div class="empty-state-desc">Add a VNC or RDP connection. You'll need an hs:// key for the remote peer.</div>
</div>`;
return;
}
grid.innerHTML = rdpConnections.map(conn => {
const typeBadge = conn.type === 'rdp'
? `<span class="badge badge-amber" style="font-size:10px;padding:2px 7px;">RDP</span>`
: `<span class="badge badge-cyan" style="font-size:10px;padding:2px 7px;">VNC</span>`;
const metaUser = conn.type === 'rdp' && conn.username ? escapeHtml(conn.username) + '@rdp' : conn.type.toUpperCase();
return `
<div class="rdp-conn-card" data-rdp-id="${escapeHtml(conn.id)}">
<div class="rdp-conn-card-top">
<div class="rdp-conn-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</div>
<div class="rdp-conn-info">
<div class="rdp-conn-label">${escapeHtml(conn.label || (conn.type === 'rdp' ? 'RDP Desktop' : 'VNC Desktop'))} ${typeBadge}</div>
</div>
<div class="rdp-conn-actions">
<button class="btn btn-primary" data-rdp-connect="${escapeHtml(conn.id)}" style="padding:6px 14px;font-size:12px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px;"><polyline points="5,12 19,12"/><polyline points="12,5 19,12 12,19"/></svg>
Connect
</button>
<button class="btn btn-ghost" data-rdp-edit="${escapeHtml(conn.id)}" style="padding:6px 10px;" title="Edit">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="btn btn-ghost" data-rdp-remove="${escapeHtml(conn.id)}" style="padding:6px 10px;color:var(--red);" title="Remove">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><polyline points="3,6 5,6 21,6"/><path d="M19,6l-1,14a2,2,0,0,1-2,2H8a2,2,0,0,1-2-2L5,6"/></svg>
</button>
</div>
</div>
</div>`;
}).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 = '<div style="color:var(--red);padding:20px;font-size:13px;">noVNC (RFB) not loaded. Check vendor/novnc.js.</div>';
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 = '<div style="color:var(--red);padding:20px;font-size:13px;">Failed to init noVNC: ' + escapeHtml(e.message) + '</div>';
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 = '<div style="color:var(--red);padding:20px;font-size:13px;">WebSocket failed: ' + escapeHtml(e.message) + '</div>';
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 = `<tr><td colspan="4"><div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:32px;height:32px;color:var(--text4)"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17,8 12,3 7,8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div class="empty-state-title">No backups yet</div>
<div class="empty-state-desc">Click "Take Backup" to create your first backup</div>
</div></td></tr>`;
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 `<tr>
<td><span class="mono" style="font-size:12px;">${name}</span></td>
<td>${created}</td>
<td>${size}</td>
<td>
<div style="display:flex;gap:6px;">
<button class="btn btn-secondary btn-sm" data-backup-restore="${escapeHtml(b.filename)}">Restore</button>
<button class="btn btn-danger btn-sm" data-backup-delete="${escapeHtml(b.filename)}">Delete</button>
</div>
</td>
</tr>`;
}).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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17,8 12,3 7,8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> 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 <tbody>
// - an IntersectionObserver on a sentinel <div> 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 = `<tr><td colspan="${this.cols}" class="empty">${this.emptyMsg}</td></tr>`;
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 `<tr>
<td style="font-size:12px;"><span class="mono-chip">${escapeHtml(h)}</span></td>
<td>${stateTag(v.state)}</td>
<td><a href="https://${escapeHtml(h)}" target="_blank" rel="noopener"
class="btn btn-ghost btn-sm" style="padding:3px 8px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
style="width:11px;height:11px;">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15,3 21,3 21,9"/><line x1="10" y1="14" x2="21" y2="3"/>
</svg>Open</a></td>
</tr>`;
}
});
_ovServers = new OvList({
bodyId: 'ovServersBody', sentinelId: 'ovServersSentinel',
searchId: 'ovServersSearch', subtitleId: 'ovServersSubtitle',
cols: 3, emptyMsg: 'No server tunnels',
rowFn: s => {
const key = s.hsUrl || '';
const short = key.length > 22 ? key.slice(0, 9) + '…' + key.slice(-7) : key;
return `<tr>
<td class="mono" style="font-size:12px;">${escapeHtml(String(s.port || '—'))}</td>
<td>${stateTag(s.state)}</td>
<td class="mono" style="font-size:11px;color:var(--text3);"
title="${escapeHtml(key)}">${escapeHtml(short)}</td>
</tr>`;
}
});
_ovSvc = new OvList({
bodyId: 'ovSvcBody', sentinelId: 'ovSvcSentinel',
searchId: 'ovSvcSearch', subtitleId: 'ovSvcSubtitle',
cols: 3, emptyMsg: 'No service tunnels',
rowFn: t => `<tr>
<td style="font-size:12px;">${escapeHtml(t.label || '—')}</td>
<td class="mono" style="font-size:12px;">${escapeHtml(String(t.localPort || '—'))}</td>
<td>${stateTag(t.state)}</td>
</tr>`
});
_ovSsh = new OvList({
bodyId: 'ovSshBody', sentinelId: 'ovSshSentinel',
searchId: 'ovSshSearch', subtitleId: 'ovSshSubtitle',
cols: 2, emptyMsg: 'No SSH connections',
rowFn: c => `<tr>
<td style="font-size:12px;">${escapeHtml(c.label || c.hsUrl || '—')}</td>
<td class="mono" style="font-size:12px;color:var(--text3);">${escapeHtml(c.username || '—')}</td>
</tr>`
});
_ovRdp = new OvList({
bodyId: 'ovRdpBody', sentinelId: 'ovRdpSentinel',
searchId: 'ovRdpSearch', subtitleId: 'ovRdpSubtitle',
cols: 2, emptyMsg: 'No remote desktops',
rowFn: c => `<tr>
<td style="font-size:12px;">${escapeHtml(c.label || '—')}</td>
<td><span class="badge badge-neutral" style="font-size:10px;padding:2px 6px;">
${escapeHtml((c.type || 'vnc').toUpperCase())}</span></td>
</tr>`
});
// Quick action buttons → open existing modals directly
$('qaAddVhost') ?.addEventListener('click', () => openModal('modal-addVhost'));
$('qaAddServer') ?.addEventListener('click', () => openModal('modal-startServer'));
$('qaAddSvc') ?.addEventListener('click', () => openModal('modal-addServiceTunnel'));
$('qaAddSsh') ?.addEventListener('click', () => openAddSshModal(null));
$('qaAddRdp') ?.addEventListener('click', () => openAddRdpModal(null));
$('qaInstallCA') ?.addEventListener('click', () => openModal('modal-installCA'));
}
// ── Dashboard / Overview ─────────────────────────────────────────────────────
function updateDashboard(state) {
currentState = state;
const servers = state.servers || [];
const virtualHosts = state.virtualHosts || [];
const serviceTunnels = state.serviceTunnels || [];
// Sidebar status
const dot = $('sidebarDot');
const statusText = $('sidebarStatus');
if (state.hostConnected) {
dot?.classList.add('connected');
dot?.classList.remove('disconnected');
if (statusText) statusText.textContent = 'Connected';
} else {
dot?.classList.remove('connected');
dot?.classList.add('disconnected');
if (statusText) statusText.textContent = 'Disconnected';
}
const setText = (id, val) => { const el = $(id); if (el) el.textContent = val; };
// ── Stat cards ────────────────────────────────────────────────────────────
setText('dashConnections', virtualHosts.length);
setText('dashSwarms', servers.length);
setText('dashServiceTunnels', serviceTunnels.length);
setText('dashSsh', sshConnections.length);
setText('dashRdp', rdpConnections.length);
setText('dashUptime', state.caInstalled ? 'Trusted' : 'Not trusted');
const caIcon = $('caStatIcon');
if (caIcon) {
caIcon.style.background = state.caInstalled ? 'var(--green-dim)' : 'var(--red-dim)';
caIcon.style.color = state.caInstalled ? 'var(--green)' : 'var(--red)';
}
// ── System status bar ─────────────────────────────────────────────────────
const ovHostDot = $('ovHostDot');
if (ovHostDot) {
ovHostDot.classList.toggle('connected', !!state.hostConnected);
ovHostDot.classList.toggle('disconnected', !state.hostConnected);
}
setText('ovHostLabel', state.hostConnected ? 'Connected' : 'Disconnected');
const ovCaDot = $('ovCaDot');
if (ovCaDot) {
ovCaDot.style.background = state.caInstalled ? 'var(--green)' : 'var(--red)';
ovCaDot.style.boxShadow = state.caInstalled ? '0 0 5px var(--green)' : 'none';
}
setText('ovCaLabel', state.caInstalled ? 'Installed & trusted' : 'Not installed');
setText('ovProxyLabel', state.proxyPort != null ? `port ${state.proxyPort}` : '—');
setText('ovConnectLabel', state.connectProxyPort != null ? `port ${state.connectProxyPort}` : '—');
const lu = $('overviewLastUpdated');
if (lu) lu.textContent = 'Updated ' + new Date().toLocaleTimeString();
// ── Nav badges ────────────────────────────────────────────────────────────
setText('swarmCount', servers.length);
setText('connCount', virtualHosts.length);
setText('serviceTunnelCount', serviceTunnels.length);
setText('sshCount', sshConnections.length);
setText('rdpCount', rdpConnections.length);
setText('tabCount', state.caInstalled ? 'CA ✓' : 'CA');
// ── Initialise list engines on first call ─────────────────────────────────
if (!_ovVhost) initOvLists();
// ── Feed data into each list (subtitle auto-computed) ─────────────────────
const sub = (n, unit, readyArr) => {
if (n === 0) return `No ${unit}s`;
const r = readyArr.filter(x => x.state === 'ready').length;
return `${n} ${unit}${n !== 1 ? 's' : ''}${r} ready`;
};
_ovVhost .setData(virtualHosts, sub(virtualHosts.length, 'host', virtualHosts));
_ovServers.setData(servers, sub(servers.length, 'tunnel', servers));
_ovSvc .setData(serviceTunnels, sub(serviceTunnels.length, 'tunnel', serviceTunnels));
_ovSsh .setData(sshConnections, sshConnections.length === 0
? 'No SSH connections' : `${sshConnections.length} saved`);
_ovRdp .setData(rdpConnections, rdpConnections.length === 0
? 'No remote desktops' : `${rdpConnections.length} saved`);
}
// ── Virtual Hosts table ──────────────────────────────────────────────────────
function updateConnectionsTable(state) {
const tbody = $('connectionsTable');
if (!tbody) return;
const virtualHosts = state.virtualHosts || [];
const proxyPort = state.proxyPort || 8443;
if (virtualHosts.length === 0) {
tbody.innerHTML = `
<tr><td colspan="5">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="2"/><circle cx="4" cy="6" r="2"/><circle cx="20" cy="6" r="2"/><circle cx="4" cy="18" r="2"/><circle cx="20" cy="18" r="2"/><line x1="6" y1="6" x2="10" y2="11"/><line x1="18" y1="6" x2="14" y2="11"/><line x1="6" y1="18" x2="10" y2="13"/><line x1="18" y1="18" x2="14" y2="13"/></svg>
<div class="empty-state-title">No virtual hosts</div>
<div class="empty-state-desc">Click "Add Host" to assign a hostname to an hs:// tunnel</div>
</div>
</td></tr>`;
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, '&quot;');
const needsReconnect = v.state === 'error' || v.state === 'closed';
return `
<tr>
<td><span class="mono-chip">${escapeHtml(hostname)}</span></td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<span class="mono" title="${escapeHtml(hsUrl)}" style="color:var(--text3);font-size:11px;">${truncate(hsUrl, 28)}</span>
<button class="btn-icon copy-btn" data-copy="${escapeHtml(hsUrl)}" title="Copy hs:// URL" style="flex-shrink:0;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span class="copy-tooltip">Copied!</span>
</button>
</div>
</td>
<td class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(backend)}</td>
<td>${stateTag(v.state)}</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<a href="${openUrl}" target="_blank" rel="noopener" class="btn btn-secondary btn-sm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15,3 21,3 21,9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Open
</a>
${needsReconnect ? `<button class="btn btn-secondary btn-sm" data-reconnect-vhost="${safeHostname}" data-hs-url="${escapeHtml(hsUrl)}" title="Reconnect tunnel">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23,4 23,10 17,10"/><path d="M20.49 15a9 9 0 1 1-.07-8.13"/></svg>
Reconnect
</button>` : ''}
<button class="btn btn-danger btn-sm" data-remove-vhost="${safeHostname}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19,6l-1,14a2,2,0,0,1-2,2H8a2,2,0,0,1-2-2L5,6"/></svg>
Remove
</button>
</div>
</td>
</tr>`;
}).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 = `
<tr><td colspan="5">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="6" height="6" rx="1"/><rect x="16" y="3" width="6" height="6" rx="1"/><rect x="9" y="15" width="6" height="6" rx="1"/><line x1="5" y1="9" x2="12" y2="15"/><line x1="19" y1="9" x2="12" y2="15"/></svg>
<div class="empty-state-title">No server tunnels</div>
<div class="empty-state-desc">Click "New Server" to expose a local port as an hs:// tunnel</div>
</div>
</td></tr>`;
return;
}
tbody.innerHTML = servers.map(s => {
const id = s.id || s.serverId || '';
const url = s.url || s.hsUrl || '';
const safeId = id.replace(/"/g, '&quot;');
return `
<tr>
<td class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(truncate(id, 20))}</td>
<td style="font-weight:600;color:var(--text);">${s.port ?? '—'}</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<span class="mono" title="${escapeHtml(url)}" style="font-size:11px;color:var(--cyan);">${truncate(url, 30)}</span>
${url ? `<button class="btn-icon copy-btn" data-copy="${escapeHtml(url)}" title="Copy hs:// URL">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span class="copy-tooltip">Copied!</span>
</button>` : ''}
</div>
</td>
<td>
<span class="badge badge-neutral" style="margin-right:4px;">${s.udp ? 'UDP' : 'TCP'}</span>${s.secure ? `<span class="badge badge-green">secure</span>` : `<span class="badge badge-neutral">plain</span>`}
</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<button class="btn btn-ghost btn-sm" data-edit-server="${safeId}" title="Edit server">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Edit
</button>
<button class="btn btn-danger btn-sm" data-stop-server="${safeId}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
Stop
</button>
</div>
</td>
</tr>`;
}).join('');
tbody.querySelectorAll('[data-copy]').forEach(btn => {
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
});
tbody.querySelectorAll('[data-edit-server]').forEach(btn => {
btn.addEventListener('click', () => {
const serverId = btn.dataset.editServer;
const server = (state.servers || []).find(s => (s.id || s.serverId) === serverId);
if (!server) return;
$('serverEditId').value = serverId;
const portEl = $('startServerPort');
const hostEl = $('startServerHost');
const secureEl = $('startServerSecure');
if (portEl) portEl.value = server.port ?? 3000;
if (hostEl) hostEl.value = server.host ?? '127.0.0.1';
if (secureEl) secureEl.checked = server.secure !== false;
const udpEl = document.querySelector('input[name="startServerProtocol"][value="' + (server.udp ? 'udp' : 'tcp') + '"]');
if (udpEl) udpEl.checked = true;
const titleEl = $('modal-startServer-title');
if (titleEl) titleEl.textContent = 'Edit Server Tunnel';
const submitEl = $('startServerSubmit');
if (submitEl) submitEl.textContent = 'Save Changes';
openModal('modal-startServer');
});
});
tbody.querySelectorAll('[data-stop-server]').forEach(btn => {
btn.addEventListener('click', () => {
const serverId = btn.dataset.stopServer;
const nameEl = $('stopServerName');
if (nameEl) nameEl.textContent = serverId;
$('stopServerConfirm').dataset.serverId = serverId;
openModal('modal-stopServer');
});
});
}
// ── Proxy & CA page ──────────────────────────────────────────────────────────
function updateTabsTable(state) {
const portEl = $('proxyInfoPort');
const caEl = $('proxyInfoCA');
if (portEl) portEl.textContent = state.proxyPort != null ? state.proxyPort : '—';
if (caEl) {
caEl.textContent = state.caInstalled ? 'Installed' : 'Not installed';
caEl.style.color = state.caInstalled ? 'var(--green)' : 'var(--red)';
}
// Show validator only when CA is installed
const validatorCard = $('certValidatorCard');
if (validatorCard) validatorCard.style.display = state.caInstalled ? '' : 'none';
if (state.caInstalled) {
renderValidatorTable(state.virtualHosts || []);
}
}
// ── Certificate Validator ─────────────────────────────────────────────────────
// Per-host test results: hostname -> { status, tlsOk, httpStatus, ms, error }
const validationResults = new Map();
function renderValidatorTable(virtualHosts) {
const tbody = $('certValidatorBody');
if (!tbody) return;
if (virtualHosts.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="empty">No virtual hosts to test — add one in Virtual Hosts</td></tr>`;
return;
}
tbody.innerHTML = virtualHosts.map(v => {
const hostname = v.hostname || '';
const r = validationResults.get(hostname);
return `<tr id="vrow-${CSS.escape(hostname)}">
<td><span class="mono-chip">${escapeHtml(hostname)}</span></td>
<td>${renderTlsCell(hostname, r)}</td>
<td>${renderStatusCell(hostname, r)}</td>
<td>${renderTimeCell(hostname, r)}</td>
<td>
<button class="btn btn-secondary btn-sm" data-validate-host="${escapeHtml(hostname)}" id="vbtn-${CSS.escape(hostname)}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px;"><polygon points="5,3 19,12 5,21"/></svg>
Test
</button>
</td>
</tr>`;
}).join('');
tbody.querySelectorAll('[data-validate-host]').forEach(btn => {
btn.addEventListener('click', () => runValidation(btn.dataset.validateHost));
});
}
function renderTlsCell(hostname, r) {
if (!r) return `<span class="badge badge-neutral">—</span>`;
if (r.status === 'running') return `<span class="badge badge-amber">Testing…</span>`;
if (r.tlsOk) return `<span class="badge badge-green">✓ Trusted</span>`;
return `<span class="badge badge-red" title="${escapeHtml(r.error || '')}">✗ Untrusted</span>`;
}
function renderStatusCell(hostname, r) {
if (!r || r.status === 'running') return `<span style="color:var(--text4);">—</span>`;
if (!r.tlsOk) return `<span style="color:var(--text4);">—</span>`;
if (r.httpStatus == null) return `<span class="badge badge-red">No response</span>`;
const cls = r.httpStatus < 400 ? 'badge-green' : r.httpStatus < 500 ? 'badge-amber' : 'badge-red';
return `<span class="badge ${cls}">${r.httpStatus}</span>`;
}
function renderTimeCell(hostname, r) {
if (!r || r.status === 'running' || !r.tlsOk || r.ms == null) return `<span style="color:var(--text4);">—</span>`;
const color = r.ms < 500 ? 'var(--green)' : r.ms < 2000 ? 'var(--amber)' : 'var(--red)';
return `<span style="font-family:'JetBrains Mono',monospace;font-size:12px;color:${color};">${r.ms}ms</span>`;
}
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')
? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px;animation:spin 1s linear infinite;"><polyline points="23,4 23,10 17,10"/><path d="M20.49 15a9 9 0 1 1-.07-8.13"/></svg> Testing…`
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px;"><polygon points="5,3 19,12 5,21"/></svg> 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 = `
<tr><td colspan="5">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<div class="empty-state-title">No service tunnels</div>
<div class="empty-state-desc">Click "Add Tunnel" to connect a remote TCP/UDP service via Holesail</div>
</div>
</td></tr>`;
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, '&quot;');
return `
<tr>
<td style="font-weight:600;color:var(--text);">${escapeHtml(label)}</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<span class="mono" title="${escapeHtml(hsUrl)}" style="font-size:11px;color:var(--text3);">${truncate(hsUrl, 28)}</span>
${hsUrl ? `<button class="btn-icon copy-btn" data-copy="${escapeHtml(hsUrl)}" title="Copy hs:// key">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span class="copy-tooltip">Copied!</span>
</button>` : ''}
</div>
</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-weight:600;color:var(--cyan);font-family:'JetBrains Mono',monospace;font-size:13px;">${escapeHtml(localAddr)}</span>
${t.localPort != null ? `<button class="btn-icon copy-btn" data-copy="${escapeHtml(localAddr)}" title="Copy local address">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span class="copy-tooltip">Copied!</span>
</button>` : ''}
</div>
</td>
<td>${stateTag(t.state)}</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
${(t.state === 'error' || t.state === 'closed') ? `
<button class="btn btn-ghost btn-sm" data-reconnect-svc="${safeId}" data-hsurl="${escapeHtml(hsUrl)}" data-label="${escapeHtml(label)}" data-localport="${t.localPort != null ? t.localPort : ''}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23,4 23,10 17,10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Reconnect
</button>` : ''}
<button class="btn btn-ghost btn-sm" data-edit-service-tunnel="${safeId}" title="Edit tunnel">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Edit
</button>
<button class="btn btn-danger btn-sm" data-remove-service-tunnel="${safeId}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19,6l-1,14a2,2,0,0,1-2,2H8a2,2,0,0,1-2-2L5,6"/></svg>
Remove
</button>
</div>
</td>
</tr>`;
}).join('');
tbody.querySelectorAll('[data-copy]').forEach(btn => {
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
});
tbody.querySelectorAll('[data-reconnect-svc]').forEach(btn => {
btn.addEventListener('click', () => {
const tunnelId = btn.dataset.reconnectSvc;
const hsUrl = btn.dataset.hsurl;
const label = btn.dataset.label;
const localPort = parseInt(btn.dataset.localport, 10);
btn.disabled = true;
btn.textContent = 'Reconnecting…';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'updateServiceTunnel', payload: { tunnelId, hsUrl, label, localPort } } },
(response) => {
if (chrome.runtime.lastError) { showToast('Reconnect failed: ' + chrome.runtime.lastError.message, 'error'); return; }
if (response?.ok) {
showToast('Service tunnel reconnecting…', 'success');
} else {
showToast(response?.error || 'Reconnect failed', 'error');
}
refresh();
}
);
});
});
tbody.querySelectorAll('[data-edit-service-tunnel]').forEach(btn => {
btn.addEventListener('click', () => {
const tunnelId = btn.dataset.editServiceTunnel;
const tunnel = (state.serviceTunnels || []).find(t => t.id === tunnelId);
if (!tunnel) return;
$('serviceTunnelEditId').value = tunnelId;
$('serviceTunnelLabel').value = tunnel.label || '';
$('serviceTunnelHsUrl').value = tunnel.hsUrl || '';
$('serviceTunnelLocalPort').value = tunnel.localPort != null ? tunnel.localPort : '';
const titleEl = $('modal-addServiceTunnel-title');
if (titleEl) titleEl.textContent = 'Edit Service Tunnel';
const submitEl = $('serviceTunnelSubmit');
if (submitEl) submitEl.textContent = 'Save';
openModal('modal-addServiceTunnel');
});
});
tbody.querySelectorAll('[data-remove-service-tunnel]').forEach(btn => {
btn.addEventListener('click', () => {
const tunnelId = btn.dataset.removeServiceTunnel;
const tunnel = (state.serviceTunnels || []).find(t => t.id === tunnelId);
const nameEl = $('removeServiceTunnelName');
if (nameEl) nameEl.textContent = (tunnel && tunnel.label) || tunnelId;
$('removeServiceTunnelConfirm').dataset.tunnelId = tunnelId;
openModal('modal-removeServiceTunnel');
});
});
}
// ── Refresh ──────────────────────────────────────────────────────────────────
async function refresh() {
const state = await fetchState();
if (state) {
// Sync SSH connections from native host state — decode base64 password if present
if (Array.isArray(state.sshConnections)) {
sshConnections = state.sshConnections.map(c => {
const existing = sshConnections.find(e => e.id === c.id);
// Prefer in-memory password (user just typed it), then decode persisted base64
let password = (existing && existing.password) || '';
if (!password && c.passwordB64) {
try { password = decodeURIComponent(escape(atob(c.passwordB64))); } catch (_) {}
}
return { ...c, password };
});
renderSshGrid();
}
// Sync RDP connections from native host state — decode base64 password if present
if (Array.isArray(state.rdpConnections)) {
rdpConnections = state.rdpConnections.map(c => {
const existing = rdpConnections.find(e => e.id === c.id);
// Prefer in-memory password (user just typed it), then decode persisted base64
let password = (existing && existing.password) || '';
if (!password && c.passwordB64) {
try { password = decodeURIComponent(escape(atob(c.passwordB64))); } catch (_) {}
}
return { ...c, password };
});
renderRdpGrid();
}
// Sync settings from native host state
if (state.settings && typeof state.settings === 'object') {
settings = { ...SETTINGS_DEFAULTS, ...state.settings };
}
updateDashboard(state);
updateConnectionsTable(state);
updateSwarmsTable(state);
updateTabsTable(state);
updateServiceTunnelsTable(state);
updateSettingsUI();
}
const sshCountEl = $('sshCount');
if (sshCountEl) sshCountEl.textContent = sshConnections.length;
refreshBackups();
}
// ── Events ───────────────────────────────────────────────────────────────────
function setupEvents() {
// FAB refresh
// Toggle switches
document.querySelectorAll('.toggle').forEach(toggle => {
toggle.addEventListener('click', () => toggle.classList.toggle('active'));
});
// Save settings
$('btnSaveSettings')?.addEventListener('click', saveSettings);
// Reset settings to defaults
$('btnResetSettings')?.addEventListener('click', () => {
settings = { ...SETTINGS_DEFAULTS };
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'updateSettings', payload: { ...settings } } },
(response) => {
if (response && response.settings) settings = { ...SETTINGS_DEFAULTS, ...response.settings };
updateSettingsUI();
showToast('Settings reset to defaults', 'success');
}
);
});
// ── Add Virtual Host ────────────────────────────────────────────────────
$('addVhostBtn')?.addEventListener('click', () => openModal('modal-addVhost'));
$('addVhostSubmit')?.addEventListener('click', () => {
const hostnameEl = $('addVhostHostname');
const hsUrlEl = $('addVhostHsUrl');
// Sanitize: strip protocol, port, path, trailing slashes
let hostname = (hostnameEl?.value || '').trim();
hostname = hostname.replace(/^https?:\/\//i, '').replace(/[/:?#].*$/, '').toLowerCase().trim();
const hsUrl = (hsUrlEl?.value || '').trim();
const hostnameValidation = isValidVhostHostname(hostname);
if (!hostnameValidation.ok) { showModalError('modal-addVhost', 'addVhostError', hostnameValidation.error); return; }
if (!hsUrl || !hsUrl.startsWith('hs://')) { showModalError('modal-addVhost', 'addVhostError', 'Enter a valid hs:// URL'); return; }
const btn = $('addVhostSubmit');
if (btn) { btn.disabled = true; btn.textContent = 'Adding…'; }
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname, hsUrl } } },
(response) => {
if (btn) { btn.disabled = false; btn.textContent = 'Add Host'; }
if (response?.ok) {
if (hostnameEl) hostnameEl.value = '';
if (hsUrlEl) hsUrlEl.value = '';
closeModal('modal-addVhost');
showToast('Virtual host added', 'success');
refresh();
} else {
showModalError('modal-addVhost', 'addVhostError', response?.error || 'Failed to add');
}
}
);
});
// ── Remove Virtual Host ─────────────────────────────────────────────────
$('removeVhostConfirm')?.addEventListener('click', () => {
const hostname = $('removeVhostConfirm').dataset.hostname;
if (!hostname) return;
const btn = $('removeVhostConfirm');
btn.disabled = true; btn.textContent = 'Removing…';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'removeVirtualHost', payload: { hostname } } },
(response) => {
btn.disabled = false; btn.textContent = 'Remove';
closeModal('modal-removeVhost');
if (response?.ok) {
showToast('Virtual host removed', 'success');
} else {
showToast(response?.error || 'Failed to remove', 'error');
}
refresh();
}
);
});
// ── Start Server ────────────────────────────────────────────────────────
$('startServerBtn')?.addEventListener('click', () => {
// Reset to "new server" mode
const editIdEl = $('serverEditId');
if (editIdEl) editIdEl.value = '';
const titleEl = $('modal-startServer-title');
if (titleEl) titleEl.textContent = 'Start Server Tunnel';
const submitEl = $('startServerSubmit');
if (submitEl) submitEl.textContent = 'Start Server';
const tcpEl = $('startServerProtocolTcp');
if (tcpEl) tcpEl.checked = true;
openModal('modal-startServer');
});
$('startServerSubmit')?.addEventListener('click', () => {
const portEl = $('startServerPort');
const hostEl = $('startServerHost');
const secureEl = $('startServerSecure');
const port = parseInt(portEl?.value, 10) || 3000;
const host = (hostEl?.value || '127.0.0.1').trim();
const secure = secureEl?.checked !== false;
const udp = document.querySelector('input[name="startServerProtocol"]:checked')?.value === 'udp';
const editId = ($('serverEditId')?.value || '').trim();
if (!port || port < 1 || port > 65535) { showModalError('modal-startServer', 'startServerError', 'Port must be 165535'); return; }
const btn = $('startServerSubmit');
if (btn) { btn.disabled = true; btn.textContent = editId ? 'Saving…' : 'Starting…'; }
const doStart = () => {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'startServer', payload: { port, host, secure, udp } } },
(response) => {
if (btn) { btn.disabled = false; btn.textContent = editId ? 'Save Changes' : 'Start Server'; }
if (response?.ok) {
if (editIdEl) editIdEl.value = '';
closeModal('modal-startServer');
showToast(editId ? 'Server updated' : 'Server started', 'success');
refresh();
} else {
showModalError('modal-startServer', 'startServerError', response?.error || 'Failed to start');
}
}
);
};
if (editId) {
// Stop old server first, then start new one with updated settings
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServer', payload: { serverId: editId } } },
() => doStart()
);
} else {
doStart();
}
});
// ── Stop Server ─────────────────────────────────────────────────────────
$('stopServerConfirm')?.addEventListener('click', () => {
const serverId = $('stopServerConfirm').dataset.serverId;
if (!serverId) return;
const btn = $('stopServerConfirm');
btn.disabled = true; btn.textContent = 'Stopping…';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServer', payload: { serverId } } },
(response) => {
btn.disabled = false; btn.textContent = 'Stop Server';
closeModal('modal-stopServer');
if (response?.ok) showToast('Server stopped', 'success');
else showToast(response?.error || 'Failed to stop', 'error');
refresh();
}
);
});
// ── Install CA ──────────────────────────────────────────────────────────
$('installCaBtn')?.addEventListener('click', () => openModal('modal-installCA'));
$('installCaSubmit')?.addEventListener('click', () => {
const btn = $('installCaSubmit');
const errEl = $('installCaError');
const successEl = $('installCaSuccess');
if (btn) { btn.disabled = true; btn.textContent = 'Installing…'; }
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
if (successEl) { successEl.style.display = 'none'; }
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'installRootCA', payload: {} } },
(response) => {
if (btn) { btn.disabled = false; btn.textContent = 'Install CA'; }
if (response?.ok) {
if (successEl) { successEl.textContent = '✓ Root CA installed. Fully quit and reopen Chrome (Cmd+Q) to apply trust.'; successEl.style.display = 'block'; }
showToast('Root CA installed', 'success');
setTimeout(() => closeModal('modal-installCA'), 2000);
refresh();
} else {
if (errEl) { errEl.textContent = response?.error || 'Installation failed'; errEl.style.display = 'block'; }
}
}
);
});
// ── Logs ────────────────────────────────────────────────────────────────
let logs = [];
let autoScroll = true;
let logFilter = '';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'registerDashboard' },
(response) => {
if (response && response.logs) { logs = response.logs; updateLogsDisplay(); }
}
);
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'holesail-logs' && message.logs) {
logs = message.logs;
updateLogsDisplay();
}
});
function getLogClass(msg) {
const m = (msg || '').toLowerCase();
if (m.includes('error') || m.includes('fail') || m.includes('err:')) return 'is-error';
if (m.includes('warn') || m.includes('warning')) return 'is-warn';
return '';
}
function updateLogsDisplay() {
const container = $('logsContainer');
if (!container) return;
const filtered = logFilter
? logs.filter(e => (e.message || '').toLowerCase().includes(logFilter.toLowerCase()))
: logs;
if (filtered.length === 0) {
container.innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
<div class="empty-state-title">${logFilter ? 'No matching logs' : 'No logs yet'}</div>
<div class="empty-state-desc">${logFilter ? 'Try a different filter' : 'Logs will appear here as the extension runs'}</div>
</div>`;
return;
}
container.innerHTML = filtered.map(entry => {
const time = new Date(entry.timestamp).toLocaleTimeString();
const cls = getLogClass(entry.message);
return `<div class="log-entry">
<span class="log-time">${time}</span>
<span class="log-msg ${cls}">${escapeHtml(entry.message)}</span>
</div>`;
}).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 165535'); 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 = `
<div class="empty-state" style="grid-column:1/-1">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="8,10 12,14 16,10"/></svg>
<div class="empty-state-title">No SSH connections</div>
<div class="empty-state-desc">Add a connection to get started. You'll need an hs:// key for the remote peer.</div>
</div>`;
return;
}
grid.innerHTML = sshConnections.map(conn => `
<div class="ssh-conn-card" data-ssh-id="${escapeHtml(conn.id)}">
<div class="ssh-conn-card-top">
<div class="ssh-conn-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<polyline points="8,10 12,14 16,10"/>
</svg>
</div>
<div class="ssh-conn-info">
<div class="ssh-conn-label">${escapeHtml(conn.label || conn.username + '@ssh')}</div>
</div>
<div class="ssh-conn-actions">
<button class="btn btn-primary" data-ssh-connect="${escapeHtml(conn.id)}" style="padding:6px 14px;font-size:12px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px;"><polyline points="5,12 19,12"/><polyline points="12,5 19,12 12,19"/></svg>
Connect
</button>
<button class="btn btn-ghost" data-ssh-edit="${escapeHtml(conn.id)}" style="padding:6px 10px;" title="Edit">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="btn btn-ghost" data-ssh-remove="${escapeHtml(conn.id)}" style="padding:6px 10px;color:var(--red);" title="Remove">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><polyline points="3,6 5,6 21,6"/><path d="M19,6l-1,14a2,2,0,0,1-2,2H8a2,2,0,0,1-2-2L5,6"/></svg>
</button>
</div>
</div>
</div>`).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();