New historyButtonsOpacity setting (10-100 percent, default 50) in the terminal menu (Display & Interaction) and the Web UI. Upgrade now also copies the Web UI app to /opt/kiosk-webui and restarts it, so new Web UI settings appear after an upgrade (it previously kept its old copy). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
1961 lines
63 KiB
JavaScript
1961 lines
63 KiB
JavaScript
const {app,BrowserWindow,BrowserView,globalShortcut,ipcMain,dialog,session}=require('electron');
|
|
const {exec,spawn}=require('child_process');
|
|
const net=require('net');
|
|
const fs=require('fs');
|
|
const path=require('path');
|
|
const os=require('os');
|
|
const crypto=require('crypto');
|
|
// Local-file sites with their own viewer pages: a folder plays as an image
|
|
// slideshow (slideshow.js), a .docx renders as a Word document (docview.js).
|
|
const slideshow=require('./slideshow');
|
|
const docview=require('./docview');
|
|
function resolveLocalSite(url){return docview.resolveSiteUrl(slideshow.resolveSiteUrl(url));}
|
|
|
|
// Suppress EPIPE errors (happen when no terminal attached)
|
|
process.stdout.on('error',(e)=>{if(e.code!=='EPIPE')throw e;});
|
|
process.stderr.on('error',(e)=>{if(e.code!=='EPIPE')throw e;});
|
|
process.on('uncaughtException',(e)=>{
|
|
if(e.code==='EPIPE')return;
|
|
console.error('Uncaught:',e);
|
|
});
|
|
|
|
const CONFIG_FILE=path.join(__dirname,'config.json');
|
|
const VERSION='1.0.3';
|
|
|
|
let mainWindow,views=[],hiddenViews=[],tabs=[],currentIndex=0,showingHidden=false;
|
|
let pinWindow=null,promptWindow=null,pauseWindow=null,htmlKeyboardWindow=null;
|
|
let pinWindowTimer=null,pauseWindowTimer=null;
|
|
const DIALOG_TIMEOUT=30000; // 30 seconds for secondary screens
|
|
let tabIndexToViewIndex=[];
|
|
let currentHiddenIndex=0;
|
|
|
|
let masterTimer=null;
|
|
let siteStartTime=Date.now();
|
|
let lastUserInteraction=Date.now();
|
|
let lastMediaCheck=Date.now();
|
|
let keyboardOpenTime=0;
|
|
let keyboardLastUsed=0;
|
|
let inactivityExtensionUntil=0;
|
|
|
|
let manualNavigationMode=false;
|
|
let programmaticNavigation=false;
|
|
|
|
let mediaIsPlaying=false;
|
|
let userRecentlyActive=false;
|
|
let keyboardIsOpen=false;
|
|
let keyboardClosePending=false;
|
|
|
|
const USER_ACTIVITY_PAUSE=60000;
|
|
const KEYBOARD_AUTO_CLOSE=30000;
|
|
const MEDIA_CHECK_INTERVAL=3000;
|
|
const MEDIA_GRACE_PERIOD=30000;
|
|
const SAFETY_MAX_EXTENSION=14400000;
|
|
const INACTIVITY_PROMPT_TIMEOUT=15000;
|
|
|
|
let lastMediaStateChange=Date.now();
|
|
|
|
let homeTabIndex=-1;
|
|
let inactivityTimeout=120000;
|
|
let allowNavigation='same-origin';
|
|
let enablePauseButton=true;
|
|
let enableKeyboardButton=true;
|
|
let enableNavButton=true;
|
|
let enableHistoryButtons=false;
|
|
let historyButtonsOpacity=50;
|
|
let enablePasswordProtection=false;
|
|
let lockoutPassword="";
|
|
let lockoutTimeout=0;
|
|
let lockoutAtTime="";
|
|
let lockoutActiveStart="";
|
|
let lockoutActiveEnd="";
|
|
let requirePasswordOnBoot=false;
|
|
let lockoutCodeType='password'; // 'password' or 'pin' (4-8 digits, keypad)
|
|
let bootPassword=''; // separate boot code hash; '' = use lockoutPassword
|
|
let bootCodeType='password';
|
|
|
|
// Password lockout state
|
|
let isLockedOut=false;
|
|
let lockoutMode='unlock'; // 'boot' while the boot lock screen is up
|
|
const lockoutThrottle=createCodeThrottle();
|
|
let lockoutWindow=null;
|
|
let lockoutTimer=null;
|
|
let lockoutActivityTime=Date.now();
|
|
let requirePasswordAfterDisplay=false;
|
|
let lastScheduledLockCheck=0;
|
|
|
|
// Authelia auto-login state
|
|
let autheliaURL='';
|
|
let autheliaUsername='';
|
|
let autheliaEncryptedPassword='';
|
|
|
|
function loadConfig(){
|
|
try{
|
|
if(!fs.existsSync(CONFIG_FILE)){
|
|
console.log('[CONFIG] No config file found');
|
|
return [];
|
|
}
|
|
|
|
const data=fs.readFileSync(CONFIG_FILE,'utf8');
|
|
const config=JSON.parse(data);
|
|
|
|
homeTabIndex=(config.homeTabIndex!=null)?config.homeTabIndex:-1;
|
|
inactivityTimeout=(config.inactivityTimeout||120)*1000;
|
|
allowNavigation=config.allowNavigation||'same-origin';
|
|
enablePauseButton=(config.enablePauseButton!==false);
|
|
enableKeyboardButton=(config.enableKeyboardButton!==false);
|
|
enableNavButton=(config.enableNavButton!==false);
|
|
enableHistoryButtons=(config.enableHistoryButtons===true);
|
|
{
|
|
const pct=Number(config.historyButtonsOpacity);
|
|
historyButtonsOpacity=(Number.isInteger(pct)&&pct>=10&&pct<=100)?pct:50;
|
|
}
|
|
enablePasswordProtection=(config.enablePasswordProtection===true);
|
|
lockoutPassword=config.lockoutPassword||"";
|
|
lockoutTimeout=(config.lockoutTimeout||0)*60000; // Convert minutes to ms
|
|
lockoutAtTime=config.lockoutAtTime||"";
|
|
lockoutActiveStart=config.lockoutActiveStart||"";
|
|
lockoutActiveEnd=config.lockoutActiveEnd||"";
|
|
requirePasswordOnBoot=(config.requirePasswordOnBoot===true);
|
|
lockoutCodeType=(config.lockoutCodeType==='pin')?'pin':'password';
|
|
bootPassword=config.bootPassword||'';
|
|
bootCodeType=(config.bootCodeType==='pin')?'pin':'password';
|
|
autheliaURL=config.autheliaURL||'';
|
|
autheliaUsername=config.autheliaUsername||'';
|
|
autheliaEncryptedPassword=config.autheliaEncryptedPassword||'';
|
|
|
|
console.log('[CONFIG] ═════════════════════════════════');
|
|
console.log('[CONFIG] Home tab index:',homeTabIndex);
|
|
console.log('[CONFIG] Inactivity timeout:',inactivityTimeout/1000,'seconds');
|
|
console.log('[CONFIG] Navigation:',allowNavigation);
|
|
console.log('[CONFIG] Pause button:',enablePauseButton);
|
|
console.log('[CONFIG] Keyboard button:',enableKeyboardButton);
|
|
console.log('[CONFIG] Back/forward buttons:',enableHistoryButtons,'opacity',historyButtonsOpacity+'%');
|
|
console.log('[CONFIG] Password protection:',enablePasswordProtection);
|
|
console.log('[CONFIG] Lockout timeout:',lockoutTimeout/60000,'minutes');
|
|
if(lockoutAtTime)console.log('[CONFIG] Lock at time:',lockoutAtTime);
|
|
if(lockoutActiveStart&&lockoutActiveEnd)console.log('[CONFIG] Active hours:',lockoutActiveStart,'-',lockoutActiveEnd);
|
|
console.log('[CONFIG] Require password on boot:',requirePasswordOnBoot);
|
|
console.log('[CONFIG] Unlock code:',lockoutCodeType,'| boot code:',bootPassword?('separate '+bootCodeType):'same as unlock');
|
|
console.log('[CONFIG] Sites:',config.tabs?.length||0);
|
|
console.log('[CONFIG] ╚═══════════════════════════════╝');
|
|
|
|
return config.tabs||[];
|
|
}catch(e){
|
|
console.error('[CONFIG] Load error:',e.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function markActivity(){
|
|
const now=Date.now();
|
|
const timeSinceLastActivity=now-lastUserInteraction;
|
|
|
|
if(timeSinceLastActivity>5000){
|
|
console.log('[ACTIVITY] User interaction detected');
|
|
}
|
|
|
|
lastUserInteraction=now;
|
|
userRecentlyActive=true;
|
|
|
|
if(promptWindow&&!promptWindow.isDestroyed()){
|
|
console.log('[ACTIVITY] Closing inactivity prompt');
|
|
promptWindow.close();
|
|
promptWindow=null;
|
|
}
|
|
}
|
|
|
|
function markKeyboardActivity(){
|
|
const now=Date.now();
|
|
keyboardLastUsed=now;
|
|
keyboardOpenTime=now;
|
|
keyboardClosePending=false;
|
|
}
|
|
|
|
// Wrong-code slowdown, shared by the lock screen and the hidden-sites PIN
|
|
// (one throttle per code): after every 5 wrong codes in a row, input is
|
|
// refused for 30s, doubling up to 5 minutes - so a 4-digit PIN can't
|
|
// simply be tried through. A right code resets it.
|
|
function createCodeThrottle(){
|
|
return{failures:0,blockedUntil:0};
|
|
}
|
|
function throttleWaitSeconds(t){
|
|
return Math.max(0,Math.ceil((t.blockedUntil-Date.now())/1000));
|
|
}
|
|
// Counts a wrong code; returns the seconds input is now refused for (0 = none).
|
|
function throttleFail(t){
|
|
t.failures++;
|
|
if(t.failures%5!==0)return 0;
|
|
const wait=Math.min(300,30*Math.pow(2,t.failures/5-1));
|
|
t.blockedUntil=Date.now()+wait*1000;
|
|
return wait;
|
|
}
|
|
|
|
function sha256Hex(v){
|
|
return crypto.createHash('sha256').update(String(v==null?'':v)).digest('hex');
|
|
}
|
|
|
|
// Hidden-sites PIN (.jitsi-pin, set in Core Settings -> Hidden Sites PIN):
|
|
// "NOPIN" (no PIN), "sha256:<hex>", or - written before the PIN was
|
|
// hashed - the plain digits, which migrateHiddenPin() rewrites hashed at
|
|
// startup. No file = the default PIN 1234. Read on every check, so a
|
|
// change from the menu applies without a restart. Guards the hidden
|
|
// sites (F10) and the WiFi screen, with one shared slowdown.
|
|
const HIDDEN_PIN_FILE=path.join(__dirname,'.jitsi-pin');
|
|
const hiddenPinThrottle=createCodeThrottle();
|
|
|
|
function readHiddenPinEntry(){
|
|
try{
|
|
const stored=fs.readFileSync(HIDDEN_PIN_FILE,'utf8').trim();
|
|
if(stored==='NOPIN')return{none:true};
|
|
if(stored.startsWith('sha256:'))return{hash:stored.slice(7)};
|
|
if(stored)return{hash:sha256Hex(stored)};
|
|
}catch(e){}
|
|
return{hash:sha256Hex('1234')};
|
|
}
|
|
|
|
function migrateHiddenPin(){
|
|
try{
|
|
const stored=fs.readFileSync(HIDDEN_PIN_FILE,'utf8').trim();
|
|
if(/^[0-9]{4,8}$/.test(stored)){
|
|
fs.writeFileSync(HIDDEN_PIN_FILE,'sha256:'+sha256Hex(stored)+'\n',{mode:0o600});
|
|
console.log('[PIN] Hidden-sites PIN converted to hashed form');
|
|
}
|
|
}catch(e){}
|
|
}
|
|
|
|
function hiddenPinRequired(){
|
|
return!readHiddenPinEntry().none;
|
|
}
|
|
|
|
// checkCode(THROTTLE, HASH, CODE, LABEL) - CODE against the stored SHA-256
|
|
// HASH, with THROTTLE's slowdown. {ok} or {ok:false, error, waitSeconds?}.
|
|
function checkCode(throttle,hash,code,label){
|
|
const blocked=throttleWaitSeconds(throttle);
|
|
if(blocked)return{ok:false,waitSeconds:blocked,error:'Too many wrong tries - wait '+blocked+'s'};
|
|
if(sha256Hex(code)===hash){
|
|
throttle.failures=0;
|
|
return{ok:true};
|
|
}
|
|
const wait=throttleFail(throttle);
|
|
console.log('[CODE] Wrong',label+', attempt',throttle.failures);
|
|
return wait?{ok:false,waitSeconds:wait,error:'Too many wrong tries - wait '+wait+'s'}:{ok:false,error:'Incorrect '+label};
|
|
}
|
|
|
|
function checkHiddenPin(pin){
|
|
const entry=readHiddenPinEntry();
|
|
if(entry.none)return{ok:true};
|
|
return checkCode(hiddenPinThrottle,entry.hash,pin,'PIN');
|
|
}
|
|
|
|
// Password lockout functions
|
|
function showLockoutScreen(mode){
|
|
if(isLockedOut||!enablePasswordProtection||!lockoutPassword)return;
|
|
|
|
isLockedOut=true;
|
|
lockoutMode=(mode==='boot')?'boot':'unlock';
|
|
console.log('[LOCKOUT] Showing lockout screen');
|
|
closeWifiWindow();
|
|
|
|
// Detach all browser views to prevent content from being visible
|
|
console.log('[LOCKOUT] Detaching all browser views for security');
|
|
views.forEach(view=>{
|
|
if(mainWindow&&!mainWindow.isDestroyed()){
|
|
try{
|
|
mainWindow.removeBrowserView(view);
|
|
}catch(e){
|
|
console.log('[LOCKOUT] View already detached or error:',e.message);
|
|
}
|
|
}
|
|
});
|
|
hiddenViews.forEach(view=>{
|
|
if(mainWindow&&!mainWindow.isDestroyed()){
|
|
try{
|
|
mainWindow.removeBrowserView(view);
|
|
}catch(e){
|
|
console.log('[LOCKOUT] Hidden view already detached or error:',e.message);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Create lockout window
|
|
lockoutWindow=new BrowserWindow({
|
|
fullscreen:true,
|
|
frame:false,
|
|
backgroundColor:'#000000',
|
|
webPreferences:{
|
|
nodeIntegration:true,
|
|
contextIsolation:false
|
|
}
|
|
});
|
|
|
|
// The boot screen takes the separate boot code when one is set.
|
|
const codeType=(lockoutMode==='boot'&&bootPassword)?bootCodeType:lockoutCodeType;
|
|
lockoutWindow.loadFile(path.join(__dirname,'lockscreen.html'),{query:{type:codeType,mode:lockoutMode}});
|
|
|
|
lockoutWindow.on('closed',()=>{
|
|
lockoutWindow=null;
|
|
});
|
|
}
|
|
|
|
function unlockScreen(){
|
|
if(!isLockedOut)return;
|
|
|
|
console.log('[LOCKOUT] Unlocking screen');
|
|
isLockedOut=false;
|
|
requirePasswordAfterDisplay=false;
|
|
|
|
if(lockoutWindow&&!lockoutWindow.isDestroyed()){
|
|
lockoutWindow.close();
|
|
lockoutWindow=null;
|
|
}
|
|
|
|
// Restore current view
|
|
if(showingHidden&&hiddenViews[currentHiddenIndex]){
|
|
try{
|
|
const[w,h]=mainWindow.getContentSize();
|
|
mainWindow.addBrowserView(hiddenViews[currentHiddenIndex]);
|
|
mainWindow.setTopBrowserView(hiddenViews[currentHiddenIndex]);
|
|
hiddenViews[currentHiddenIndex].setBounds({x:0,y:0,width:w,height:h});
|
|
}catch(e){
|
|
console.error('[LOCKOUT] Error restoring hidden view:',e);
|
|
if(views.length>0){
|
|
showingHidden=false;
|
|
attachView(0);
|
|
}
|
|
}
|
|
}else if(views.length>0){
|
|
const idx=(currentIndex>=0&¤tIndex<views.length)?currentIndex:0;
|
|
attachView(idx);
|
|
}
|
|
|
|
if(!masterTimer){
|
|
startMasterTimer();
|
|
}
|
|
|
|
lockoutActivityTime=Date.now();
|
|
}
|
|
|
|
function isWithinActiveHours(){
|
|
if(!lockoutActiveStart||!lockoutActiveEnd)return true;
|
|
|
|
const now=new Date();
|
|
const currentTime=now.getHours()*60+now.getMinutes();
|
|
|
|
const[startH,startM]=lockoutActiveStart.split(':').map(Number);
|
|
const[endH,endM]=lockoutActiveEnd.split(':').map(Number);
|
|
const startMinutes=startH*60+startM;
|
|
const endMinutes=endH*60+endM;
|
|
|
|
if(startMinutes>endMinutes){
|
|
return currentTime>=startMinutes||currentTime<endMinutes;
|
|
}
|
|
|
|
return currentTime>=startMinutes&¤tTime<endMinutes;
|
|
}
|
|
|
|
function checkScheduledLockTime(){
|
|
if(!lockoutAtTime){
|
|
return;
|
|
}
|
|
if(isLockedOut)return;
|
|
|
|
const now=new Date();
|
|
const currentHour=now.getHours();
|
|
const currentMin=now.getMinutes();
|
|
const[targetH,targetM]=lockoutAtTime.split(':').map(Number);
|
|
|
|
const currentMinute=currentHour*60+currentMin;
|
|
const targetMinute=targetH*60+targetM;
|
|
|
|
if(Math.floor(Date.now()/60000)!==Math.floor((Date.now()-1000)/60000)){
|
|
const timeStr=String(currentHour).padStart(2,'0')+':'+String(currentMin).padStart(2,'0');
|
|
console.log('[LOCKOUT-SCHED] Current: '+timeStr+' ('+currentMinute+' min) | Target: '+lockoutAtTime+' ('+targetMinute+' min) | Match: '+(currentMinute===targetMinute));
|
|
}
|
|
|
|
if(currentMinute===targetMinute&&lastScheduledLockCheck!==currentMinute){
|
|
lastScheduledLockCheck=currentMinute;
|
|
console.log('[LOCKOUT] *** SCHEDULED LOCK TIME REACHED: '+lockoutAtTime+' ***');
|
|
showLockoutScreen();
|
|
}
|
|
}
|
|
|
|
function checkLockoutTimer(){
|
|
if(!enablePasswordProtection){
|
|
return;
|
|
}
|
|
if(isLockedOut)return;
|
|
|
|
const now=Date.now();
|
|
|
|
checkScheduledLockTime();
|
|
|
|
if(lockoutTimeout>0){
|
|
if(!isWithinActiveHours()){
|
|
if(Math.floor(now/60000)!==Math.floor((now-1000)/60000)){
|
|
console.log('[LOCKOUT] Outside active hours, lockout disabled');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if(inactivityExtensionUntil>0&&now<inactivityExtensionUntil){
|
|
const remaining=Math.floor((inactivityExtensionUntil-now)/1000);
|
|
const remMin=Math.floor(remaining/60);
|
|
const remSec=remaining%60;
|
|
|
|
if(Math.floor(now/30000)!==Math.floor((now-1000)/30000)){
|
|
console.log('[LOCKOUT-INACT] ⸻ Extension active: '+remMin+'m '+remSec+'s remaining - lockout paused');
|
|
}
|
|
return;
|
|
}else if(inactivityExtensionUntil>0&&now>=inactivityExtensionUntil){
|
|
console.log('[LOCKOUT-INACT] ⏰ Extension expired - resetting lockout timer');
|
|
inactivityExtensionUntil=0;
|
|
lockoutActivityTime=now;
|
|
}
|
|
|
|
const timeSinceActivity=now-lockoutActivityTime;
|
|
const minutesSinceActivity=Math.floor(timeSinceActivity/60000);
|
|
const lockoutMinutes=Math.floor(lockoutTimeout/60000);
|
|
|
|
if(Math.floor(timeSinceActivity/30000)!==Math.floor((timeSinceActivity-1000)/30000)){
|
|
console.log('[LOCKOUT-INACT] Idle: '+minutesSinceActivity+'m / '+lockoutMinutes+'m');
|
|
}
|
|
|
|
if(timeSinceActivity>=lockoutTimeout){
|
|
console.log('[LOCKOUT] Inactivity timeout reached, locking screen');
|
|
showLockoutScreen();
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkMediaPlayback(){
|
|
let view=null;
|
|
if(showingHidden&&hiddenViews[currentHiddenIndex]){
|
|
view=hiddenViews[currentHiddenIndex];
|
|
}else if(views[currentIndex]){
|
|
view=views[currentIndex];
|
|
}
|
|
|
|
if(!view||!view.webContents){
|
|
if(mediaIsPlaying){
|
|
mediaIsPlaying=false;
|
|
lastMediaStateChange=Date.now();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if(view.webContents.isLoadingMainFrame()||!view.webContents.getURL()){
|
|
return;
|
|
}
|
|
|
|
view.webContents.executeJavaScript(`
|
|
(function(){
|
|
try{
|
|
let playing=false;
|
|
let method='';
|
|
let details='';
|
|
|
|
const videos=document.querySelectorAll("video");
|
|
for(let v of videos){
|
|
if(!v.paused&&!v.ended&&v.readyState>=2&&v.currentTime>0){
|
|
playing=true;
|
|
method='video';
|
|
details='HTML5 video';
|
|
break;
|
|
}
|
|
}
|
|
|
|
if(!playing){
|
|
const audios=document.querySelectorAll("audio");
|
|
for(let a of audios){
|
|
if(!a.paused&&!a.ended&&a.readyState>=2&&a.currentTime>0){
|
|
playing=true;
|
|
method='audio';
|
|
details='HTML5 audio';
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if(!playing){
|
|
const iframes=document.querySelectorAll(
|
|
'iframe[src*="youtube"],iframe[src*="vimeo"],'+
|
|
'iframe[src*="dailymotion"],iframe[src*="twitch"],'+
|
|
'iframe[src*="plex"],iframe[src*="emby"],iframe[src*="jellyfin"]'
|
|
);
|
|
for(let iframe of iframes){
|
|
const rect=iframe.getBoundingClientRect();
|
|
if(rect.width>200&&rect.height>100&&rect.top<window.innerHeight&&rect.bottom>0){
|
|
playing=true;
|
|
method='iframe';
|
|
const src=iframe.src||'';
|
|
if(src.includes('youtube'))details='YouTube';
|
|
else if(src.includes('plex'))details='Plex';
|
|
else if(src.includes('emby'))details='Emby';
|
|
else if(src.includes('jellyfin'))details='Jellyfin';
|
|
else if(src.includes('vimeo'))details='Vimeo';
|
|
else details='Embedded player';
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if(!playing){
|
|
if(document.querySelector('.Player-progressBar')||
|
|
document.querySelector('[class*="PlayerControls"]')){
|
|
const plexVideo=document.querySelector('video');
|
|
if(plexVideo&&!plexVideo.paused){
|
|
playing=true;
|
|
method='plex-app';
|
|
details='Plex Web';
|
|
}
|
|
}
|
|
|
|
if(document.querySelector('.videoPlayerContainer')||
|
|
document.querySelector('.nowPlayingBar')){
|
|
const jellyfinVideo=document.querySelector('video');
|
|
if(jellyfinVideo&&!jellyfinVideo.paused){
|
|
playing=true;
|
|
method='jellyfin-app';
|
|
details='Jellyfin Web';
|
|
}
|
|
}
|
|
|
|
if(document.querySelector('.videoPlayerContainer')||
|
|
document.querySelector('.nowPlayingBar')){
|
|
const embyVideo=document.querySelector('video');
|
|
if(embyVideo&&!embyVideo.paused){
|
|
playing=true;
|
|
method='emby-app';
|
|
details='Emby Web';
|
|
}
|
|
}
|
|
}
|
|
|
|
return {playing:playing,method:method,details:details};
|
|
}catch(e){
|
|
return {playing:false,error:e.message};
|
|
}
|
|
})();
|
|
`,true).then(result=>{
|
|
const wasPlaying=mediaIsPlaying;
|
|
const now=Date.now();
|
|
|
|
if(result&&result.playing){
|
|
if(!wasPlaying){
|
|
console.log('[MEDIA] ▶ Started:',result.details||result.method);
|
|
}
|
|
mediaIsPlaying=true;
|
|
lastMediaStateChange=now;
|
|
}else{
|
|
if(wasPlaying){
|
|
console.log('[MEDIA] ⸻ Stopped');
|
|
}
|
|
mediaIsPlaying=false;
|
|
if(wasPlaying){
|
|
lastMediaStateChange=now;
|
|
}
|
|
}
|
|
}).catch(err=>{});
|
|
}
|
|
|
|
function startMasterTimer(){
|
|
if(masterTimer){
|
|
clearInterval(masterTimer);
|
|
}
|
|
|
|
console.log('[TIMER] ════ MASTER TIMER STARTED ════');
|
|
console.log('[TIMER] Home tab index:',homeTabIndex);
|
|
console.log('[TIMER] Inactivity timeout:',inactivityTimeout/1000,'seconds');
|
|
console.log('[TIMER] Password protection:',enablePasswordProtection);
|
|
if(enablePasswordProtection){
|
|
console.log('[TIMER] Lockout timeout:',lockoutTimeout/60000,'minutes');
|
|
if(lockoutAtTime)console.log('[TIMER] Scheduled lock time:',lockoutAtTime);
|
|
if(lockoutActiveStart&&lockoutActiveEnd)console.log('[TIMER] Active hours:',lockoutActiveStart,'-',lockoutActiveEnd);
|
|
}
|
|
console.log('[TIMER] ╚═══════════════════════════════╝');
|
|
|
|
siteStartTime=Date.now();
|
|
lastUserInteraction=Date.now();
|
|
|
|
masterTimer=setInterval(()=>{
|
|
const now=Date.now();
|
|
|
|
// 1. KEYBOARD AUTO-CLOSE
|
|
if(keyboardIsOpen&&!keyboardClosePending){
|
|
const idleTime=now-keyboardLastUsed;
|
|
if(idleTime>KEYBOARD_AUTO_CLOSE){
|
|
keyboardClosePending=true;
|
|
closeHTMLKeyboard();
|
|
}
|
|
}
|
|
|
|
// 1.5. LOCKOUT TIMER CHECK
|
|
checkLockoutTimer();
|
|
|
|
// 1.6. CHECK FOR DISPLAY WAKE FLAG
|
|
const displayWakeFlag=path.join(__dirname,'.display-wake');
|
|
if(enablePasswordProtection&&lockoutPassword&&fs.existsSync(displayWakeFlag)){
|
|
console.log('[LOCKOUT] Display wake detected, requiring password');
|
|
fs.unlinkSync(displayWakeFlag);
|
|
if(!isLockedOut){
|
|
showLockoutScreen();
|
|
}
|
|
}
|
|
|
|
// CRITICAL: If locked out, skip all navigation/rotation logic
|
|
if(isLockedOut){
|
|
return;
|
|
}
|
|
|
|
// 2. MEDIA CHECK
|
|
if(now-lastMediaCheck>MEDIA_CHECK_INTERVAL){
|
|
checkMediaPlayback();
|
|
lastMediaCheck=now;
|
|
}
|
|
|
|
// 3. MEDIA BLOCKING
|
|
if(mediaIsPlaying){
|
|
return;
|
|
}
|
|
|
|
// 4. GRACE PERIOD
|
|
const timeSinceMediaStopped=now-lastMediaStateChange;
|
|
if(timeSinceMediaStopped<MEDIA_GRACE_PERIOD){
|
|
return;
|
|
}
|
|
|
|
// 5. USER ACTIVITY CHECK
|
|
const timeSinceInteraction=now-lastUserInteraction;
|
|
userRecentlyActive=timeSinceInteraction<USER_ACTIVITY_PAUSE;
|
|
|
|
if(userRecentlyActive){
|
|
return;
|
|
}
|
|
|
|
// 6. SITE ROTATION
|
|
if(!showingHidden&&views.length>1){
|
|
if(pauseWindow&&!pauseWindow.isDestroyed()){
|
|
return;
|
|
}
|
|
|
|
if(promptWindow&&!promptWindow.isDestroyed()){
|
|
return;
|
|
}
|
|
|
|
if(inactivityExtensionUntil>0&&now<inactivityExtensionUntil){
|
|
if(Math.floor(now/30000)!==Math.floor((now-1000)/30000)){
|
|
const remaining=Math.floor((inactivityExtensionUntil-now)/1000);
|
|
const remMin=Math.floor(remaining/60);
|
|
const remSec=remaining%60;
|
|
console.log('[ROTATION] ⸻ Paused - Extension active: '+remMin+'m '+remSec+'s remaining');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const currentTabIdx=viewIndexToTabIndex(currentIndex);
|
|
|
|
if(currentTabIdx>=0&&tabs[currentTabIdx]){
|
|
const siteDuration=parseInt(tabs[currentTabIdx].duration)||0;
|
|
|
|
if(siteDuration>0){
|
|
const timeOnSite=now-siteStartTime;
|
|
|
|
if(timeOnSite>=siteDuration*1000){
|
|
rotateToNextSite();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 7. HOME RETURN CHECK (manual and hidden sites)
|
|
if(homeTabIndex>=0){
|
|
if(pauseWindow&&!pauseWindow.isDestroyed()){
|
|
return;
|
|
}
|
|
|
|
if(promptWindow&&!promptWindow.isDestroyed()){
|
|
return;
|
|
}
|
|
|
|
let needsInactivityCheck=false;
|
|
let currentSiteDuration=-999;
|
|
|
|
if(showingHidden){
|
|
needsInactivityCheck=true;
|
|
currentSiteDuration=-1;
|
|
}else{
|
|
const homeViewIdx=getHomeViewIndex();
|
|
const currentTabIdx=viewIndexToTabIndex(currentIndex);
|
|
|
|
if(homeViewIdx>=0&¤tIndex!==homeViewIdx&¤tTabIdx>=0&&tabs[currentTabIdx]){
|
|
currentSiteDuration=parseInt(tabs[currentTabIdx].duration)||0;
|
|
|
|
if(currentSiteDuration===0){
|
|
needsInactivityCheck=true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if(needsInactivityCheck){
|
|
const idleTime=now-lastUserInteraction;
|
|
|
|
let effectiveTimeout=inactivityTimeout;
|
|
if(inactivityExtensionUntil>0&&now<inactivityExtensionUntil){
|
|
if(Math.floor(idleTime/15000)!==Math.floor((idleTime-1000)/15000)){
|
|
const remaining=Math.floor((inactivityExtensionUntil-now)/1000);
|
|
const remMin=Math.floor(remaining/60);
|
|
const remSec=remaining%60;
|
|
console.log('[HOME] ⏰ Extended mode: '+remMin+'m '+remSec+'s remaining');
|
|
}
|
|
return;
|
|
}else if(inactivityExtensionUntil>0&&now>=inactivityExtensionUntil){
|
|
console.log('[HOME] ⏰ Extension expired - resetting inactivity timer');
|
|
inactivityExtensionUntil=0;
|
|
lastUserInteraction=now;
|
|
siteStartTime=now;
|
|
}
|
|
|
|
if(Math.floor(idleTime/15000)!==Math.floor((idleTime-1000)/15000)){
|
|
const idleMinutes=Math.floor(idleTime/60000);
|
|
const idleSeconds=Math.floor((idleTime%60000)/1000);
|
|
const timeoutMinutes=Math.floor(effectiveTimeout/60000);
|
|
const timeoutSeconds=Math.floor((effectiveTimeout%60000)/1000);
|
|
const siteType=currentSiteDuration===-1?'HIDDEN':'MANUAL';
|
|
console.log('[HOME] 🏠 '+siteType+' IDLE: '+idleMinutes+'m '+idleSeconds+'s / '+timeoutMinutes+'m '+timeoutSeconds+'s');
|
|
}
|
|
|
|
if(idleTime>=effectiveTimeout){
|
|
console.log('[HOME] 🔔 *** SHOWING PROMPT NOW ('+
|
|
(currentSiteDuration===-1?'hidden tab':'manual site')+') ***');
|
|
showInactivityPrompt();
|
|
}
|
|
}
|
|
}
|
|
},1000);
|
|
}
|
|
|
|
function stopMasterTimer(){
|
|
if(masterTimer){
|
|
clearInterval(masterTimer);
|
|
masterTimer=null;
|
|
}
|
|
}
|
|
|
|
function rotateToNextSite(){
|
|
if(isLockedOut)return;
|
|
if(!views.length||showingHidden)return;
|
|
|
|
let nextIdx=(currentIndex+1)%views.length;
|
|
const startIdx=nextIdx;
|
|
let found=false;
|
|
let attempts=0;
|
|
|
|
do{
|
|
const tabIdx=viewIndexToTabIndex(nextIdx);
|
|
if(tabIdx>=0&&tabs[tabIdx]){
|
|
const dur=parseInt(tabs[tabIdx].duration)||0;
|
|
if(dur>0){
|
|
found=true;
|
|
break;
|
|
}
|
|
}
|
|
nextIdx=(nextIdx+1)%views.length;
|
|
attempts++;
|
|
}while(nextIdx!==startIdx&&attempts<views.length);
|
|
|
|
if(found&&nextIdx!==currentIndex){
|
|
currentIndex=nextIdx;
|
|
attachView(currentIndex);
|
|
inactivityExtensionUntil=0;
|
|
console.log('[ROTATION] Extension cleared - rotated to new site');
|
|
}
|
|
}
|
|
|
|
function attachView(i){
|
|
closeHTMLKeyboard();
|
|
|
|
if(isLockedOut){
|
|
console.log('[MAIN] Blocked attachView - screen is locked');
|
|
return;
|
|
}
|
|
|
|
if(!mainWindow||!views[i]||showingHidden)return;
|
|
|
|
currentIndex=i;
|
|
|
|
// Remove all other views to prevent bleeding through
|
|
views.forEach((view,idx)=>{
|
|
if(idx!==i&&mainWindow&&!mainWindow.isDestroyed()){
|
|
try{
|
|
mainWindow.removeBrowserView(view);
|
|
}catch(e){
|
|
// View may not be attached, ignore
|
|
}
|
|
}
|
|
});
|
|
|
|
try{
|
|
mainWindow.addBrowserView(views[i]);
|
|
}catch(e){
|
|
console.log('[MAIN] View already attached or error:',e.message);
|
|
}
|
|
mainWindow.setTopBrowserView(views[i]);
|
|
const[w,h]=mainWindow.getContentSize();
|
|
views[i].setBounds({x:0,y:0,width:w,height:h});
|
|
|
|
// Force repaint to ensure proper rendering
|
|
if(views[i].webContents){
|
|
views[i].webContents.invalidate();
|
|
}
|
|
|
|
const tabIdx=viewIndexToTabIndex(i);
|
|
if(tabIdx>=0&&tabs[tabIdx]){
|
|
const configuredUrl=resolveLocalSite(tabs[tabIdx].url);
|
|
const currentUrl=views[i].webContents.getURL();
|
|
|
|
if(currentUrl&&!currentUrl.startsWith(configuredUrl)){
|
|
programmaticNavigation=true;
|
|
views[i].webContents.loadURL(configuredUrl);
|
|
}
|
|
|
|
const siteDuration=parseInt(tabs[tabIdx].duration)||0;
|
|
const shouldShow=enablePauseButton&&siteDuration>0;
|
|
console.log('[MAIN] Sending pause-button-visibility to tab '+tabIdx+' ('+tabs[tabIdx].url+') - duration='+siteDuration+'s, shouldShow='+shouldShow);
|
|
views[i].webContents.send('pause-button-visibility',shouldShow);
|
|
}
|
|
|
|
views[i].webContents.focus();
|
|
siteStartTime=Date.now();
|
|
}
|
|
|
|
function nextTab(){
|
|
if(isLockedOut)return;
|
|
if(!views.length||showingHidden)return;
|
|
console.log('[MANUAL] User switched tab forward → manualNavigationMode=TRUE');
|
|
manualNavigationMode=true;
|
|
currentIndex=(currentIndex+1)%views.length;
|
|
attachView(currentIndex);
|
|
markActivity();
|
|
inactivityExtensionUntil=0;
|
|
console.log('[MANUAL] Extension cleared due to manual tab switch');
|
|
}
|
|
|
|
function prevTab(){
|
|
if(isLockedOut)return;
|
|
if(!views.length||showingHidden)return;
|
|
console.log('[MANUAL] User switched tab backward → manualNavigationMode=TRUE');
|
|
manualNavigationMode=true;
|
|
currentIndex=(currentIndex-1+views.length)%views.length;
|
|
attachView(currentIndex);
|
|
markActivity();
|
|
inactivityExtensionUntil=0;
|
|
console.log('[MANUAL] Extension cleared due to manual tab switch');
|
|
}
|
|
|
|
function getHomeViewIndex(){
|
|
if(homeTabIndex<0)return -1;
|
|
if(homeTabIndex>=tabIndexToViewIndex.length)return -1;
|
|
return tabIndexToViewIndex[homeTabIndex];
|
|
}
|
|
|
|
function returnToHome(){
|
|
if(isLockedOut)return;
|
|
|
|
const homeViewIdx=getHomeViewIndex();
|
|
if(homeViewIdx<0)return;
|
|
|
|
console.log('[HOME] 🏠 RETURNING TO HOME → manualNavigationMode=FALSE');
|
|
|
|
if(showingHidden){
|
|
showingHidden=false;
|
|
currentHiddenIndex=0;
|
|
}
|
|
|
|
if(promptWindow&&!promptWindow.isDestroyed()){
|
|
promptWindow.close();
|
|
promptWindow=null;
|
|
}
|
|
|
|
manualNavigationMode=false;
|
|
currentIndex=homeViewIdx;
|
|
attachView(currentIndex);
|
|
|
|
inactivityExtensionUntil=0;
|
|
|
|
if(enablePasswordProtection&&lockoutTimeout>0&&!isLockedOut){
|
|
lockoutActivityTime=Date.now();
|
|
}
|
|
markActivity();
|
|
}
|
|
|
|
function showInactivityPrompt(){
|
|
if(promptWindow&&!promptWindow.isDestroyed())return;
|
|
|
|
promptWindow=new BrowserWindow({
|
|
width:800,
|
|
height:600,
|
|
frame:false,
|
|
alwaysOnTop:true,
|
|
parent:mainWindow,
|
|
modal:true,
|
|
webPreferences:{nodeIntegration:true,contextIsolation:false}
|
|
});
|
|
|
|
promptWindow.loadFile(path.join(__dirname,'inactivity-prompt-extended.html'));
|
|
|
|
promptWindow.on('closed',()=>{
|
|
promptWindow=null;
|
|
});
|
|
|
|
setTimeout(()=>{
|
|
if(promptWindow&&!promptWindow.isDestroyed()){
|
|
console.log('[PROMPT] No response - returning home');
|
|
promptWindow.close();
|
|
promptWindow=null;
|
|
returnToHome();
|
|
}
|
|
},INACTIVITY_PROMPT_TIMEOUT);
|
|
|
|
ipcMain.once('user-still-here',(event,minutes)=>{
|
|
if(promptWindow&&!promptWindow.isDestroyed()){
|
|
promptWindow.close();
|
|
}
|
|
promptWindow=null;
|
|
|
|
if(minutes===-1){
|
|
inactivityExtensionUntil=0;
|
|
|
|
if(enablePasswordProtection&&lockoutTimeout>0&&!isLockedOut){
|
|
lockoutActivityTime=Date.now();
|
|
}
|
|
returnToHome();
|
|
}else if(minutes===0){
|
|
inactivityExtensionUntil=0;
|
|
|
|
if(enablePasswordProtection&&lockoutTimeout>0&&!isLockedOut){
|
|
lockoutActivityTime=Date.now();
|
|
}
|
|
markActivity();
|
|
siteStartTime=Date.now();
|
|
console.log('[PROMPT] User confirmed presence - rotation timer reset');
|
|
}else{
|
|
const now=Date.now();
|
|
inactivityExtensionUntil=now+(minutes*60*1000);
|
|
lastUserInteraction=now;
|
|
siteStartTime=now;
|
|
|
|
if(enablePasswordProtection&&lockoutTimeout>0&&!isLockedOut){
|
|
lockoutActivityTime=now;
|
|
console.log('[PROMPT] Lockout timer also reset during extension');
|
|
}
|
|
|
|
console.log('[PROMPT] Extended for '+minutes+' min until: '+new Date(inactivityExtensionUntil).toLocaleTimeString());
|
|
console.log('[PROMPT] Rotation timer reset - staying on current page');
|
|
}
|
|
});
|
|
}
|
|
|
|
function showPauseDialog(){
|
|
if(pauseWindow&&!pauseWindow.isDestroyed())return;
|
|
|
|
pauseWindow=new BrowserWindow({
|
|
width:800,
|
|
height:600,
|
|
frame:false,
|
|
alwaysOnTop:true,
|
|
parent:mainWindow,
|
|
modal:true,
|
|
webPreferences:{nodeIntegration:true,contextIsolation:false}
|
|
});
|
|
|
|
pauseWindow.loadFile(path.join(__dirname,'pause-dialog.html'));
|
|
|
|
pauseWindow.on('closed',()=>{
|
|
if(pauseWindowTimer){clearTimeout(pauseWindowTimer);pauseWindowTimer=null;}
|
|
pauseWindow=null;
|
|
});
|
|
|
|
// Set 30-second auto-dismiss timer
|
|
if(pauseWindowTimer){clearTimeout(pauseWindowTimer);}
|
|
pauseWindowTimer=setTimeout(()=>{
|
|
console.log('[PAUSE] Auto-dismissing dialog after 30 seconds');
|
|
if(pauseWindow&&!pauseWindow.isDestroyed()){
|
|
pauseWindow.close();
|
|
}
|
|
pauseWindow=null;
|
|
pauseWindowTimer=null;
|
|
},DIALOG_TIMEOUT);
|
|
|
|
ipcMain.once('pause-time-selected',(event,minutes)=>{
|
|
if(pauseWindowTimer){clearTimeout(pauseWindowTimer);pauseWindowTimer=null;}
|
|
if(pauseWindow&&!pauseWindow.isDestroyed()){
|
|
pauseWindow.close();
|
|
}
|
|
pauseWindow=null;
|
|
|
|
if(minutes===0){
|
|
console.log('[PAUSE] Cancelled');
|
|
}else{
|
|
const now=Date.now();
|
|
inactivityExtensionUntil=now+(minutes*60*1000);
|
|
lastUserInteraction=now;
|
|
siteStartTime=now;
|
|
|
|
if(enablePasswordProtection&&lockoutTimeout>0&&!isLockedOut){
|
|
lockoutActivityTime=now;
|
|
console.log('[PAUSE] Lockout timer also reset during extension');
|
|
}
|
|
|
|
console.log('[PAUSE] Extended for '+minutes+' min until: '+new Date(inactivityExtensionUntil).toLocaleTimeString());
|
|
console.log('[PAUSE] Rotation and inactivity timers paused');
|
|
}
|
|
});
|
|
}
|
|
|
|
function showHTMLKeyboard(){
|
|
if(keyboardIsOpen){
|
|
keyboardLastUsed=Date.now();
|
|
keyboardOpenTime=Date.now();
|
|
keyboardClosePending=false;
|
|
return;
|
|
}
|
|
|
|
if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){
|
|
htmlKeyboardWindow.focus();
|
|
keyboardLastUsed=Date.now();
|
|
keyboardOpenTime=Date.now();
|
|
keyboardIsOpen=true;
|
|
keyboardClosePending=false;
|
|
return;
|
|
}
|
|
|
|
const{width,height}=mainWindow.getBounds();
|
|
const kbHeight=Math.floor(height*0.4);
|
|
const kbY=height-kbHeight;
|
|
|
|
htmlKeyboardWindow=new BrowserWindow({
|
|
width:width,
|
|
height:kbHeight,
|
|
x:0,
|
|
y:kbY,
|
|
frame:false,
|
|
alwaysOnTop:true,
|
|
skipTaskbar:true,
|
|
focusable:false,
|
|
webPreferences:{
|
|
nodeIntegration:true,
|
|
contextIsolation:false,
|
|
backgroundThrottling:false
|
|
}
|
|
});
|
|
|
|
htmlKeyboardWindow.loadFile(path.join(__dirname,'keyboard.html'));
|
|
|
|
htmlKeyboardWindow.webContents.on('did-finish-load',()=>{
|
|
keyboardIsOpen=true;
|
|
keyboardOpenTime=Date.now();
|
|
keyboardLastUsed=Date.now();
|
|
keyboardClosePending=false;
|
|
notifyKeyboardState(true);
|
|
|
|
if(mainWindow&&!mainWindow.isDestroyed()){
|
|
mainWindow.focus();
|
|
if(views[currentIndex]&&views[currentIndex].webContents){
|
|
views[currentIndex].webContents.focus();
|
|
}
|
|
}
|
|
});
|
|
|
|
htmlKeyboardWindow.on('closed',()=>{
|
|
keyboardIsOpen=false;
|
|
keyboardClosePending=false;
|
|
htmlKeyboardWindow=null;
|
|
notifyKeyboardState(false);
|
|
});
|
|
}
|
|
|
|
function closeHTMLKeyboard(){
|
|
if(!keyboardIsOpen)return;
|
|
|
|
const wasAutoClosed=keyboardClosePending;
|
|
|
|
if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){
|
|
htmlKeyboardWindow.close();
|
|
}
|
|
htmlKeyboardWindow=null;
|
|
keyboardIsOpen=false;
|
|
keyboardClosePending=false;
|
|
notifyKeyboardState(false);
|
|
|
|
if(wasAutoClosed){
|
|
const allViews=[...views,...hiddenViews];
|
|
allViews.forEach(view=>{
|
|
if(view&&view.webContents){
|
|
view.webContents.send('keyboard-auto-closed');
|
|
}
|
|
});
|
|
}
|
|
|
|
if(mainWindow&&!mainWindow.isDestroyed()){
|
|
mainWindow.focus();
|
|
}
|
|
}
|
|
|
|
function notifyKeyboardState(visible){
|
|
const allViews=[...views,...hiddenViews];
|
|
allViews.forEach(view=>{
|
|
if(view&&view.webContents){
|
|
view.webContents.send('keyboard-state-changed',visible);
|
|
}
|
|
});
|
|
}
|
|
|
|
function viewIndexToTabIndex(viewIdx){
|
|
for(let i=0;i<tabIndexToViewIndex.length;i++){
|
|
if(tabIndexToViewIndex[i]===viewIdx)return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function toggleHidden(){
|
|
if(pinWindow&&!pinWindow.isDestroyed()){
|
|
pinWindow.close();
|
|
pinWindow=null;
|
|
return;
|
|
}
|
|
|
|
if(!hiddenViews.length)return;
|
|
|
|
if(showingHidden){
|
|
currentHiddenIndex++;
|
|
if(currentHiddenIndex>=hiddenViews.length){
|
|
currentHiddenIndex=0;
|
|
returnToTabs();
|
|
}else{
|
|
showHiddenTab(currentHiddenIndex);
|
|
}
|
|
}else{
|
|
currentHiddenIndex=0;
|
|
showPinEntry();
|
|
}
|
|
}
|
|
|
|
function returnToTabs(){
|
|
if(!views.length)return;
|
|
|
|
const[w,h]=mainWindow.getContentSize();
|
|
mainWindow.setTopBrowserView(views[currentIndex]);
|
|
views[currentIndex].setBounds({x:0,y:0,width:w,height:h});
|
|
showingHidden=false;
|
|
currentHiddenIndex=0;
|
|
|
|
markActivity();
|
|
}
|
|
|
|
// Page back/forward in the site that's on screen (Alt+Left / Alt+Right, the
|
|
// mouse's side buttons, and the optional on-screen buttons). Not while the
|
|
// screen is locked or a dialog (PIN, WiFi, power...) is open on top - the
|
|
// keys would silently navigate the page hidden behind it.
|
|
function historyGo(direction){
|
|
if(isLockedOut)return;
|
|
for(const w of [pinWindow,wifiWindow,promptWindow,pauseWindow]){
|
|
if(w&&!w.isDestroyed())return;
|
|
}
|
|
const view=showingHidden?hiddenViews[currentHiddenIndex]:views[currentIndex];
|
|
if(!view||!view.webContents||view.webContents.isDestroyed())return;
|
|
const wc=view.webContents;
|
|
const nav=wc.navigationHistory;
|
|
if(direction==='back'){
|
|
if(nav?nav.canGoBack():wc.canGoBack()){nav?nav.goBack():wc.goBack();}
|
|
}else{
|
|
if(nav?nav.canGoForward():wc.canGoForward()){nav?nav.goForward():wc.goForward();}
|
|
}
|
|
markActivity();
|
|
}
|
|
|
|
function showPinEntry(){
|
|
if(pinWindow&&!pinWindow.isDestroyed()){
|
|
pinWindow.focus();
|
|
return;
|
|
}
|
|
|
|
pinWindow=new BrowserWindow({
|
|
width:500,
|
|
height:650,
|
|
frame:false,
|
|
alwaysOnTop:true,
|
|
parent:mainWindow,
|
|
modal:true,
|
|
webPreferences:{nodeIntegration:true,contextIsolation:false}
|
|
});
|
|
|
|
pinWindow.loadFile(path.join(__dirname,'pin-entry.html'));
|
|
pinWindow.on('closed',()=>{
|
|
if(pinWindowTimer){clearTimeout(pinWindowTimer);pinWindowTimer=null;}
|
|
pinWindow=null;
|
|
});
|
|
|
|
// Set 30-second auto-dismiss timer
|
|
if(pinWindowTimer){clearTimeout(pinWindowTimer);}
|
|
pinWindowTimer=setTimeout(()=>{
|
|
console.log('[PIN] Auto-dismissing after 30 seconds');
|
|
if(pinWindow&&!pinWindow.isDestroyed()){
|
|
pinWindow.close();
|
|
}
|
|
pinWindow=null;
|
|
pinWindowTimer=null;
|
|
},DIALOG_TIMEOUT);
|
|
|
|
ipcMain.once('pin-correct',()=>{
|
|
if(pinWindowTimer){clearTimeout(pinWindowTimer);pinWindowTimer=null;}
|
|
if(pinWindow&&!pinWindow.isDestroyed()){
|
|
pinWindow.close();
|
|
}
|
|
pinWindow=null;
|
|
showHiddenTab(currentHiddenIndex);
|
|
});
|
|
|
|
ipcMain.once('pin-cancelled',()=>{
|
|
if(pinWindowTimer){clearTimeout(pinWindowTimer);pinWindowTimer=null;}
|
|
if(pinWindow&&!pinWindow.isDestroyed()){
|
|
pinWindow.close();
|
|
}
|
|
pinWindow=null;
|
|
});
|
|
}
|
|
|
|
function showHiddenTab(index){
|
|
if(!hiddenViews[index])return;
|
|
|
|
const[w,h]=mainWindow.getContentSize();
|
|
mainWindow.setTopBrowserView(hiddenViews[index]);
|
|
hiddenViews[index].setBounds({x:0,y:0,width:w,height:h});
|
|
showingHidden=true;
|
|
}
|
|
|
|
// WiFi screen (Ctrl+Alt+Super+W): join a WiFi network from the kiosk
|
|
// itself, for when the console (Ctrl+Alt+F1..F6) is switched off and the
|
|
// admin menu can't be reached. Gated by the kiosk's unlock code - the
|
|
// lock screen's PIN or password, with its slowdown - or, when password
|
|
// protection is off, the hidden-sites PIN (see wifiGate). The root
|
|
// work is done by /usr/local/bin/kiosk-wifi-helper, which the kiosk user
|
|
// may run through sudo and nothing else (/etc/sudoers.d/kiosk-wifi).
|
|
const WIFI_HELPER='/usr/local/bin/kiosk-wifi-helper';
|
|
const WIFI_IDLE_TIMEOUT=5*60000; // closes after 5 minutes untouched
|
|
let wifiWindow=null,wifiUnlocked=false,wifiIdleTimer=null;
|
|
|
|
const WIFI_SOCKET='/run/kiosk-wifi-helper.sock';
|
|
|
|
// Parse the helper's one JSON reply line (the last line of its output).
|
|
function parseWifiReply(out){
|
|
const line=out.trim().split('\n').pop();
|
|
return JSON.parse(line);
|
|
}
|
|
|
|
// Preferred route: a root-owned socket (kiosk-wifi-helper.socket). Unlike
|
|
// sudo it keeps working when this app has the "no new privileges" flag set.
|
|
// Resolves null when the socket isn't usable (not installed yet, or this
|
|
// user isn't allowed), so the caller falls back to sudo.
|
|
function runWifiHelperSocket(action,input){
|
|
return new Promise(resolve=>{
|
|
let out='',done=false;
|
|
const finish=result=>{if(!done){done=true;clearTimeout(killer);resolve(result);}};
|
|
const sock=net.connect({path:WIFI_SOCKET,allowHalfOpen:true});
|
|
const killer=setTimeout(()=>{sock.destroy();finish({ok:false,error:'The WiFi helper timed out'});},120000);
|
|
sock.on('connect',()=>{
|
|
// Request = action line, then the JSON body; half-close so the
|
|
// helper sees end of input.
|
|
sock.end(action+'\n'+(input?JSON.stringify(input):''));
|
|
});
|
|
sock.on('data',d=>{out+=d;});
|
|
sock.on('error',e=>{
|
|
if(out){
|
|
// A reset after the reply arrived - use the reply.
|
|
try{finish(parseWifiReply(out));return;}catch(_){}
|
|
}
|
|
if(!out&&['ENOENT','ECONNREFUSED','EACCES','EPERM'].includes(e.code)){
|
|
console.log('[WIFI] Helper socket unavailable ('+e.code+') - falling back to sudo');
|
|
finish(null);
|
|
}else{
|
|
finish({ok:false,error:'Could not reach the WiFi helper: '+e.message});
|
|
}
|
|
});
|
|
sock.on('close',()=>{
|
|
if(done)return;
|
|
try{
|
|
finish(parseWifiReply(out));
|
|
}catch(e){
|
|
console.error('[WIFI] helper socket output:',out);
|
|
finish({ok:false,error:'The WiFi helper gave no usable answer'});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Fallback: the helper through passwordless sudo (/etc/sudoers.d/kiosk-wifi).
|
|
function runWifiHelperSudo(action,input){
|
|
return new Promise(resolve=>{
|
|
let out='',err='',done=false;
|
|
const finish=result=>{if(!done){done=true;resolve(result);}};
|
|
let child;
|
|
try{
|
|
child=spawn('sudo',['-n',WIFI_HELPER,action],{stdio:['pipe','pipe','pipe']});
|
|
}catch(e){
|
|
finish({ok:false,error:'Could not run the WiFi helper: '+e.message});
|
|
return;
|
|
}
|
|
const killer=setTimeout(()=>{child.kill();finish({ok:false,error:'The WiFi helper timed out'});},120000);
|
|
child.stdout.on('data',d=>{out+=d;});
|
|
child.stderr.on('data',d=>{err+=d;});
|
|
child.on('error',e=>{clearTimeout(killer);finish({ok:false,error:'Could not run the WiFi helper: '+e.message});});
|
|
child.on('close',()=>{
|
|
clearTimeout(killer);
|
|
try{
|
|
finish(parseWifiReply(out));
|
|
}catch(e){
|
|
// Not our JSON - typically sudo refusing ("a password is required",
|
|
// or the "no new privileges" flag) - Upgrade installs the socket.
|
|
console.error('[WIFI] helper output:',out,err);
|
|
finish({ok:false,error:(err.trim()||'The WiFi helper is not installed')+' - run Advanced -> Upgrade from the admin menu'});
|
|
}
|
|
});
|
|
child.stdin.on('error',()=>{});
|
|
child.stdin.end(input?JSON.stringify(input):'');
|
|
});
|
|
}
|
|
|
|
async function runWifiHelper(action,input){
|
|
const viaSocket=await runWifiHelperSocket(action,input);
|
|
if(viaSocket!==null)return viaSocket;
|
|
return runWifiHelperSudo(action,input);
|
|
}
|
|
|
|
function resetWifiIdleTimer(){
|
|
if(wifiIdleTimer)clearTimeout(wifiIdleTimer);
|
|
wifiIdleTimer=setTimeout(()=>{
|
|
console.log('[WIFI] Closing after 5 minutes idle');
|
|
closeWifiWindow();
|
|
},WIFI_IDLE_TIMEOUT);
|
|
}
|
|
|
|
function closeWifiWindow(){
|
|
if(wifiWindow&&!wifiWindow.isDestroyed())wifiWindow.close();
|
|
}
|
|
|
|
function showWifiWindow(){
|
|
if(isLockedOut)return;
|
|
if(wifiWindow&&!wifiWindow.isDestroyed()){
|
|
wifiWindow.focus();
|
|
return;
|
|
}
|
|
wifiUnlocked=!wifiGate();
|
|
let width=720,height=820;
|
|
if(mainWindow&&!mainWindow.isDestroyed()){
|
|
const[w,h]=mainWindow.getContentSize();
|
|
width=Math.min(width,w);
|
|
height=Math.min(height,h);
|
|
}
|
|
wifiWindow=new BrowserWindow({
|
|
width,height,
|
|
frame:false,
|
|
alwaysOnTop:true,
|
|
parent:mainWindow,
|
|
modal:true,
|
|
webPreferences:{nodeIntegration:true,contextIsolation:false}
|
|
});
|
|
wifiWindow.setMenu(null);
|
|
wifiWindow.loadFile(path.join(__dirname,'wifi.html'));
|
|
wifiWindow.on('closed',()=>{
|
|
if(wifiIdleTimer){clearTimeout(wifiIdleTimer);wifiIdleTimer=null;}
|
|
wifiWindow=null;
|
|
wifiUnlocked=false;
|
|
});
|
|
resetWifiIdleTimer();
|
|
console.log('[WIFI] WiFi screen opened');
|
|
}
|
|
|
|
// Which code guards the WiFi screen: the unlock code (PIN or password)
|
|
// when password protection is on, else the hidden-sites PIN; null if
|
|
// neither is set (hidden PIN turned off and no lock code).
|
|
function wifiGate(){
|
|
if(enablePasswordProtection&&lockoutPassword)return{kind:'lock',codeType:lockoutCodeType};
|
|
if(hiddenPinRequired())return{kind:'hidden',codeType:'pin'};
|
|
return null;
|
|
}
|
|
|
|
function checkWifiCode(code){
|
|
const gate=wifiGate();
|
|
if(!gate)return{ok:true};
|
|
if(gate.kind==='lock'){
|
|
return checkCode(lockoutThrottle,lockoutPassword,code,gate.codeType==='pin'?'PIN':'password');
|
|
}
|
|
return checkHiddenPin(code);
|
|
}
|
|
|
|
ipcMain.handle('wifi-request',async(event,req)=>{
|
|
if(!wifiWindow||wifiWindow.isDestroyed()||event.sender!==wifiWindow.webContents){
|
|
return{ok:false,error:'Not allowed'};
|
|
}
|
|
resetWifiIdleTimer();
|
|
const action=req&&req.action;
|
|
if(action==='close'){closeWifiWindow();return{ok:true};}
|
|
if(action==='needs-pin'){
|
|
const gate=wifiGate();
|
|
return{ok:true,needsPin:!wifiUnlocked,codeType:gate?gate.codeType:'pin'};
|
|
}
|
|
if(action==='unlock'){
|
|
if(wifiUnlocked)return{ok:true};
|
|
const result=checkWifiCode(req.pin);
|
|
if(result.ok)wifiUnlocked=true;
|
|
return result;
|
|
}
|
|
if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'};
|
|
if(action==='ifaces')return runWifiHelper('ifaces');
|
|
if(action==='status'||action==='scan'){
|
|
return runWifiHelper(action,req.iface?{iface:String(req.iface)}:null);
|
|
}
|
|
if(action==='region-status')return runWifiHelper('region-status');
|
|
if(action==='region-set'){
|
|
const input={};
|
|
for(const k of['country','locale','keyboard'])if(req[k])input[k]=String(req[k]);
|
|
console.log('[WIFI] Region set:',JSON.stringify(input));
|
|
return runWifiHelper('region-set',input);
|
|
}
|
|
if(action==='connect'){
|
|
console.log('[WIFI] Connecting to',JSON.stringify(String(req.ssid||'')));
|
|
const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||''),iface:req.iface?String(req.iface):''});
|
|
console.log('[WIFI] Connect result:',result.ok?('ok '+result.ip):result.error);
|
|
return result;
|
|
}
|
|
return{ok:false,error:'Unknown request'};
|
|
});
|
|
|
|
function forceReturnToTabs(){
|
|
closeWifiWindow();
|
|
if(pinWindow&&!pinWindow.isDestroyed()){
|
|
pinWindow.close();
|
|
pinWindow=null;
|
|
}
|
|
returnToTabs();
|
|
}
|
|
|
|
function showPowerMenu(){
|
|
const ipAddress=os.networkInterfaces();
|
|
let localIP='No IP';
|
|
let vpnIP='';
|
|
|
|
// Get local IP (exclude VPN interfaces)
|
|
for(const name of Object.keys(ipAddress)){
|
|
// Skip VPN interfaces
|
|
if(name.startsWith('tailscale')||name.startsWith('wg')||name.startsWith('netbird')||name.startsWith('tun')||name.startsWith('wt')){
|
|
continue;
|
|
}
|
|
for(const net of ipAddress[name]){
|
|
if(net.family==='IPv4'&&!net.internal){
|
|
localIP=net.address;
|
|
break;
|
|
}
|
|
}
|
|
if(localIP!=='No IP')break;
|
|
}
|
|
|
|
// Get VPN IPs (Tailscale, WireGuard, Netbird)
|
|
for(const name of Object.keys(ipAddress)){
|
|
if(name.startsWith('tailscale')||name.startsWith('wg')||name.startsWith('netbird')||name.startsWith('tun')||name.startsWith('wt')){
|
|
for(const net of ipAddress[name]){
|
|
if(net.family==='IPv4'){
|
|
vpnIP+=net.address+' ('+name+') ';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// For lockout mode, use native dialog (limited options, overlay not available)
|
|
if(isLockedOut){
|
|
console.log('[SECURITY] Showing limited power menu - system is locked out');
|
|
let ipInfo='Local: '+localIP;
|
|
if(vpnIP){ipInfo+='\nVPN: '+vpnIP.trim();}
|
|
const targetWindow=(lockoutWindow&&!lockoutWindow.isDestroyed())?lockoutWindow:mainWindow;
|
|
const r=dialog.showMessageBoxSync(targetWindow,{
|
|
type:'question',
|
|
buttons:['Shutdown','Restart','Cancel'],
|
|
defaultId:2,
|
|
title:'Power Options',
|
|
message:'System is locked. Limited options available.\n\nVersion: '+VERSION+'\n'+ipInfo,
|
|
noLink:true
|
|
});
|
|
|
|
if(r===0)exec('systemctl poweroff');
|
|
else if(r===1)exec('systemctl reboot');
|
|
return;
|
|
}
|
|
|
|
// For normal mode, use custom overlay with 30-second timeout
|
|
console.log('[POWER] Showing custom power menu overlay');
|
|
const powerInfo={version:VERSION,localIP:localIP,vpnIP:vpnIP.trim()};
|
|
// Send to all views (preload.js runs in views, not mainWindow)
|
|
for(const view of views){
|
|
if(view&&view.webContents&&!view.webContents.isDestroyed()){
|
|
view.webContents.send('display-power-menu',powerInfo);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function autheliaAuthenticate(){
|
|
if(!autheliaURL||!autheliaUsername||!autheliaEncryptedPassword)return;
|
|
try{
|
|
const machineId=fs.readFileSync('/etc/machine-id','utf8').trim();
|
|
const key=crypto.scryptSync(machineId,'kiosk-authelia-v1',32);
|
|
const buf=Buffer.from(autheliaEncryptedPassword,'base64');
|
|
const iv=buf.subarray(0,16);
|
|
const enc=buf.subarray(16);
|
|
const decipher=crypto.createDecipheriv('aes-256-cbc',key,iv);
|
|
const password=Buffer.concat([decipher.update(enc),decipher.final()]).toString('utf8');
|
|
|
|
const ctrl=new AbortController();
|
|
const timer=setTimeout(()=>ctrl.abort(),10000);
|
|
const res=await session.defaultSession.fetch(`${autheliaURL}/api/firstfactor`,{
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json','User-Agent':'kiosk/1.0'},
|
|
body:JSON.stringify({username:autheliaUsername,password,keepMeLoggedIn:true,requestMethod:'GET',targetURL:''}),
|
|
signal:ctrl.signal
|
|
}).finally(()=>clearTimeout(timer));
|
|
const body=await res.json().catch(()=>({}));
|
|
if(res.ok&&body.status==='OK'){
|
|
console.log('[AUTHELIA] Authenticated as',autheliaUsername);
|
|
}else{
|
|
console.error('[AUTHELIA] Auth failed:',res.status,body.message||'');
|
|
}
|
|
}catch(e){
|
|
console.error('[AUTHELIA] Error:',e.message);
|
|
}
|
|
}
|
|
|
|
async function createWindow(){
|
|
tabs=loadConfig();
|
|
migrateHiddenPin();
|
|
await autheliaAuthenticate();
|
|
|
|
mainWindow=new BrowserWindow({
|
|
fullscreen:true,
|
|
kiosk:true,
|
|
frame:false,
|
|
show:false,
|
|
webPreferences:{
|
|
nodeIntegration:false,
|
|
contextIsolation:true,
|
|
sandbox:false,
|
|
preload:path.join(__dirname,'preload.js')
|
|
}
|
|
});
|
|
|
|
mainWindow.setMenu(null);
|
|
mainWindow.show();
|
|
|
|
mainWindow.on('focus',()=>markActivity());
|
|
mainWindow.webContents.on('before-input-event',()=>markActivity());
|
|
// Mouse side buttons (back/forward). Reported by the window on Linux.
|
|
mainWindow.on('app-command',(event,command)=>{
|
|
if(command==='browser-backward')historyGo('back');
|
|
else if(command==='browser-forward')historyGo('forward');
|
|
});
|
|
|
|
if(!tabs.length){
|
|
mainWindow.loadURL('data:text/html,<body>No Sites Configured</body>');
|
|
return;
|
|
}
|
|
|
|
let viewIndex=0;
|
|
tabs.forEach((t,tabIdx)=>{
|
|
const view=new BrowserView({
|
|
webPreferences:{
|
|
contextIsolation:true,
|
|
sandbox:false,
|
|
preload:path.join(__dirname,'preload.js'),
|
|
backgroundThrottling:false
|
|
}
|
|
});
|
|
|
|
mainWindow.addBrowserView(view);
|
|
|
|
let url=t.url;
|
|
if(t.username&&t.password){
|
|
try{
|
|
const u=new URL(t.url);
|
|
u.username=t.username;
|
|
u.password=t.password;
|
|
url=u.toString();
|
|
}catch(e){}
|
|
}
|
|
url=resolveLocalSite(url);
|
|
|
|
const initialOrigin=new URL(t.url).origin;
|
|
|
|
if(allowNavigation==='restricted'){
|
|
view.webContents.on('will-navigate',(e,u)=>{
|
|
if(u!==url&&u!==t.url)e.preventDefault();
|
|
});
|
|
view.webContents.setWindowOpenHandler(()=>({action:'deny'}));
|
|
}else if(allowNavigation==='same-origin'){
|
|
view.webContents.on('will-navigate',(e,u)=>{
|
|
try{
|
|
if(new URL(u).origin!==initialOrigin)e.preventDefault();
|
|
}catch(x){
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
}
|
|
|
|
view.webContents.on('before-input-event',()=>markActivity());
|
|
view.webContents.on('did-start-loading',()=>{
|
|
if(!programmaticNavigation){
|
|
markActivity();
|
|
}
|
|
});
|
|
view.webContents.on('did-navigate',()=>{
|
|
if(programmaticNavigation){
|
|
programmaticNavigation=false;
|
|
}else{
|
|
markActivity();
|
|
}
|
|
});
|
|
|
|
view.webContents.setAudioMuted(false);
|
|
slideshow.attach(view.webContents);
|
|
docview.attach(view.webContents);
|
|
view.webContents.loadURL(url);
|
|
|
|
view.webContents.on('did-finish-load',()=>{
|
|
view.webContents.executeJavaScript(`
|
|
["mousedown","keydown","touchstart","scroll","click"].forEach(e=>{
|
|
document.addEventListener(e,()=>{
|
|
if(window.electronAPI?.notifyActivity){
|
|
window.electronAPI.notifyActivity();
|
|
}
|
|
},true);
|
|
});
|
|
`).catch(()=>{});
|
|
|
|
const siteDuration=parseInt(t.duration)||0;
|
|
const shouldShow=enablePauseButton&&siteDuration>0;
|
|
view.webContents.send('pause-button-visibility',shouldShow);
|
|
|
|
view.webContents.send('keyboard-button-enabled',enableKeyboardButton);
|
|
console.log('[MAIN] Page loaded - sending keyboard-button-enabled: '+enableKeyboardButton);
|
|
|
|
view.webContents.send('nav-button-enabled',enableNavButton);
|
|
view.webContents.send('history-buttons-config',{enabled:enableHistoryButtons,opacity:historyButtonsOpacity});
|
|
console.log('[MAIN] Page loaded - sending nav-button-enabled: '+enableNavButton);
|
|
|
|
console.log('[MAIN] Page loaded - resending pause-button-visibility: '+shouldShow+' for '+t.url);
|
|
});
|
|
|
|
const isHidden=parseInt(t.duration)===-1;
|
|
if(isHidden){
|
|
hiddenViews.push(view);
|
|
tabIndexToViewIndex[tabIdx]=-1;
|
|
}else{
|
|
views.push(view);
|
|
tabIndexToViewIndex[tabIdx]=viewIndex;
|
|
viewIndex++;
|
|
}
|
|
});
|
|
|
|
const homeViewIdx=getHomeViewIndex();
|
|
const startIndex=homeViewIdx>=0?homeViewIdx:0;
|
|
|
|
if(views.length){
|
|
setTimeout(()=>{
|
|
const bootFlag=path.join(__dirname,'.boot-flag');
|
|
if(enablePasswordProtection&&lockoutPassword&&requirePasswordOnBoot&&fs.existsSync(bootFlag)){
|
|
console.log('[LOCKOUT] Boot detected, requiring password BEFORE showing sites');
|
|
fs.unlinkSync(bootFlag);
|
|
showLockoutScreen('boot');
|
|
}else{
|
|
attachView(startIndex);
|
|
startMasterTimer();
|
|
}
|
|
},1000);
|
|
}
|
|
|
|
ipcMain.on('swipe-left',()=>{nextTab();});
|
|
ipcMain.on('swipe-right',()=>{prevTab();});
|
|
ipcMain.on('show-power-menu',showPowerMenu);
|
|
ipcMain.on('power-action',(event,action)=>{
|
|
console.log('[POWER] Action requested:',action);
|
|
if(action==='shutdown')exec('systemctl poweroff');
|
|
else if(action==='restart')exec('systemctl reboot');
|
|
else if(action==='reload'){app.relaunch();app.quit();}
|
|
});
|
|
ipcMain.on('toggle-hidden',toggleHidden);
|
|
ipcMain.on('history-nav',(event,direction)=>{
|
|
if(direction==='back'||direction==='forward')historyGo(direction);
|
|
});
|
|
ipcMain.on('return-to-tabs',forceReturnToTabs);
|
|
ipcMain.on('user-activity',markActivity);
|
|
ipcMain.on('show-keyboard',()=>{showHTMLKeyboard();});
|
|
ipcMain.on('close-keyboard',()=>{closeHTMLKeyboard();});
|
|
ipcMain.on('keyboard-activity',()=>{markKeyboardActivity();});
|
|
ipcMain.on('show-pause-dialog',()=>{showPauseDialog();});
|
|
// The lock screen sends the code as typed; compared here as a SHA-256
|
|
// hash (how the menu and Web UI store it). After every 5 wrong codes in
|
|
// a row the screen refuses input for a while - 30s, doubling up to 5
|
|
// minutes - so a 4-digit PIN can't simply be tried through.
|
|
// The lock screen sends the code as typed; compared here as a SHA-256
|
|
// hash (how the menu and Web UI store it), with the wrong-code slowdown
|
|
// (see createCodeThrottle).
|
|
ipcMain.on('check-lockout-password',(event,code)=>{
|
|
if(!lockoutWindow||lockoutWindow.isDestroyed()||event.sender!==lockoutWindow.webContents)return;
|
|
const expected=(lockoutMode==='boot'&&bootPassword)?bootPassword:lockoutPassword;
|
|
const result=checkCode(lockoutThrottle,expected,code,'code');
|
|
if(result.ok){
|
|
unlockScreen();
|
|
return;
|
|
}
|
|
event.sender.send('password-incorrect',{waitSeconds:result.waitSeconds||0});
|
|
});
|
|
|
|
ipcMain.handle('check-hidden-pin',(event,pin)=>{
|
|
if(!pinWindow||pinWindow.isDestroyed()||event.sender!==pinWindow.webContents)return{ok:false,error:'Not allowed'};
|
|
return checkHiddenPin(pin);
|
|
});
|
|
|
|
ipcMain.on('get-config',(event)=>{
|
|
try{
|
|
if(fs.existsSync(CONFIG_FILE)){
|
|
const data=fs.readFileSync(CONFIG_FILE,'utf8');
|
|
const config=JSON.parse(data);
|
|
event.sender.send('config-data',config);
|
|
console.log('[NAV] Sent config data to renderer');
|
|
}else{
|
|
console.error('[NAV] Config file not found');
|
|
event.sender.send('config-data',{tabs:[]});
|
|
}
|
|
}catch(err){
|
|
console.error('[NAV] Error reading config:',err);
|
|
event.sender.send('config-data',{tabs:[]});
|
|
}
|
|
});
|
|
|
|
ipcMain.on('navigate-to-tab',(event,tabIndex)=>{
|
|
console.log('[NAV] Navigate to tab '+tabIndex);
|
|
if(showingHidden){
|
|
forceReturnToTabs();
|
|
}
|
|
const viewIndex=tabIndexToViewIndex[tabIndex];
|
|
if(viewIndex!==undefined&&viewIndex>=0&&viewIndex<views.length){
|
|
console.log('[NAV] Switching to view index '+viewIndex);
|
|
currentIndex=viewIndex;
|
|
attachView(currentIndex);
|
|
markActivity();
|
|
console.log('[NAV] Navigation complete, rotation continues');
|
|
}else{
|
|
console.error('[NAV] Invalid view index:',viewIndex);
|
|
}
|
|
});
|
|
|
|
ipcMain.on('keyboard-type',(event,key)=>{
|
|
markKeyboardActivity();
|
|
|
|
let view=null;
|
|
if(showingHidden&&hiddenViews[currentHiddenIndex]){
|
|
view=hiddenViews[currentHiddenIndex];
|
|
}else if(views[currentIndex]){
|
|
view=views[currentIndex];
|
|
}
|
|
|
|
if(!view||!view.webContents)return;
|
|
|
|
const safeKey=JSON.stringify(key).slice(1,-1);
|
|
|
|
// Sets the value through the native <input>/<textarea> value setter
|
|
// instead of the instance property. Frameworks like React override the
|
|
// instance setter to track the "last known value"; setting el.value
|
|
// directly also updates that tracker, so the framework never sees a
|
|
// real change and its own controlled state stays empty. On the next
|
|
// re-render (focusing a different field, or any unrelated state change
|
|
// such as toggling a checkbox) it redraws the input from that stale
|
|
// empty state, which is why typed text appears to vanish. Going through
|
|
// the native setter keeps the tracker out of sync so the dispatched
|
|
// "input" event actually reaches the framework's handler.
|
|
const setNativeValue=`
|
|
function __kioskSetValue(el,value){
|
|
const proto=el.tagName==="TEXTAREA"?window.HTMLTextAreaElement.prototype:window.HTMLInputElement.prototype;
|
|
const setter=Object.getOwnPropertyDescriptor(proto,"value").set;
|
|
setter.call(el,value);
|
|
}
|
|
`;
|
|
|
|
if(key==='Backspace'){
|
|
view.webContents.executeJavaScript(`
|
|
(function(){
|
|
${setNativeValue}
|
|
const el=document.activeElement;
|
|
if(el&&(el.tagName==="INPUT"||el.tagName==="TEXTAREA")){
|
|
const s=el.selectionStart||0;
|
|
if(s>0){
|
|
const newValue=el.value.substring(0,s-1)+el.value.substring(el.selectionEnd||s);
|
|
__kioskSetValue(el,newValue);
|
|
el.selectionStart=el.selectionEnd=s-1;
|
|
el.dispatchEvent(new Event("input",{bubbles:true}));
|
|
}
|
|
}
|
|
})();
|
|
`).catch(()=>{});
|
|
}else if(key==='Enter'){
|
|
view.webContents.executeJavaScript(`
|
|
(function(){
|
|
${setNativeValue}
|
|
const el=document.activeElement;
|
|
if(el){
|
|
if(el.tagName==="TEXTAREA"){
|
|
const s=el.selectionStart||0;
|
|
const newValue=el.value.substring(0,s)+"\\n"+el.value.substring(el.selectionEnd||s);
|
|
__kioskSetValue(el,newValue);
|
|
el.selectionStart=el.selectionEnd=s+1;
|
|
el.dispatchEvent(new Event("input",{bubbles:true}));
|
|
}else if(el.tagName==="INPUT"){
|
|
const form=el.closest("form");
|
|
if(form){
|
|
form.dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}));
|
|
}
|
|
}
|
|
}
|
|
})();
|
|
`).catch(()=>{});
|
|
}else if(key==='Tab'){
|
|
view.webContents.executeJavaScript(`
|
|
(function(){
|
|
const selector='input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
|
|
const focusable=Array.prototype.filter.call(
|
|
document.querySelectorAll(selector),
|
|
el=>el.offsetParent!==null
|
|
);
|
|
if(focusable.length>0){
|
|
const idx=focusable.indexOf(document.activeElement);
|
|
const next=idx>=0?focusable[(idx+1)%focusable.length]:focusable[0];
|
|
next.focus();
|
|
}
|
|
})();
|
|
`).catch(()=>{});
|
|
}else if(key===' '){
|
|
view.webContents.executeJavaScript(`
|
|
(function(){
|
|
${setNativeValue}
|
|
const el=document.activeElement;
|
|
if(el&&(el.tagName==="INPUT"||el.tagName==="TEXTAREA")){
|
|
const s=el.selectionStart||0;
|
|
const newValue=el.value.substring(0,s)+" "+el.value.substring(el.selectionEnd||s);
|
|
__kioskSetValue(el,newValue);
|
|
el.selectionStart=el.selectionEnd=s+1;
|
|
el.dispatchEvent(new Event("input",{bubbles:true}));
|
|
}
|
|
})();
|
|
`).catch(()=>{});
|
|
}else if(key==='Control'||key==='Alt'){
|
|
// Ignore modifier keys - they don't work as standalone keys
|
|
return;
|
|
}else{
|
|
view.webContents.executeJavaScript(`
|
|
(function(){
|
|
${setNativeValue}
|
|
const text="${safeKey}";
|
|
const el=document.activeElement;
|
|
if(el&&(el.tagName==="INPUT"||el.tagName==="TEXTAREA")){
|
|
const s=el.selectionStart||0;
|
|
const e=el.selectionEnd||s;
|
|
const newValue=el.value.substring(0,s)+text+el.value.substring(e);
|
|
__kioskSetValue(el,newValue);
|
|
el.selectionStart=el.selectionEnd=s+text.length;
|
|
el.dispatchEvent(new Event("input",{bubbles:true}));
|
|
el.dispatchEvent(new Event("change",{bubbles:true}));
|
|
}
|
|
})();
|
|
`).catch(()=>{});
|
|
}
|
|
});
|
|
|
|
if(views.length>1){
|
|
globalShortcut.register('Control+Tab',()=>{nextTab();});
|
|
globalShortcut.register('Control+Shift+Tab',()=>{prevTab();});
|
|
globalShortcut.register('Control+]',()=>{nextTab();});
|
|
globalShortcut.register('Control+[',()=>{prevTab();});
|
|
// The standard browser keys for switching tabs. (Alt+Left/Right used to
|
|
// switch sites; they are now page back/forward, below.)
|
|
globalShortcut.register('Control+PageDown',()=>{nextTab();});
|
|
globalShortcut.register('Control+PageUp',()=>{prevTab();});
|
|
}
|
|
|
|
// Standard browser keys for page back/forward.
|
|
globalShortcut.register('Alt+Left',()=>{historyGo('back');});
|
|
globalShortcut.register('Alt+Right',()=>{historyGo('forward');});
|
|
|
|
globalShortcut.register('F10',toggleHidden);
|
|
globalShortcut.register('Control+H',toggleHidden);
|
|
globalShortcut.register('Escape',forceReturnToTabs);
|
|
globalShortcut.register('Control+Alt+Delete',showPowerMenu);
|
|
globalShortcut.register('Control+Alt+P',showPowerMenu);
|
|
globalShortcut.register('Control+Alt+Escape',showPowerMenu);
|
|
// WiFi screen - code-gated (wifiGate), so an easy-to-press combo is fine.
|
|
// Fn isn't part of it: it never reaches the computer on most keyboards.
|
|
if(!globalShortcut.register('Control+Alt+Super+W',showWifiWindow)){
|
|
console.error('[WIFI] Could not register Ctrl+Alt+Super+W');
|
|
}
|
|
globalShortcut.register('Control+K',()=>{
|
|
if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){
|
|
closeHTMLKeyboard();
|
|
}else{
|
|
showHTMLKeyboard();
|
|
}
|
|
});
|
|
|
|
mainWindow.on('resize',()=>{
|
|
const[w,h]=mainWindow.getContentSize();
|
|
if(showingHidden&&hiddenViews[currentHiddenIndex]){
|
|
hiddenViews[currentHiddenIndex].setBounds({x:0,y:0,width:w,height:h});
|
|
}else if(views[currentIndex]){
|
|
views[currentIndex].setBounds({x:0,y:0,width:w,height:h});
|
|
}
|
|
});
|
|
}
|
|
|
|
if(!app.requestSingleInstanceLock())app.quit();
|
|
|
|
// Handle SIGUSR1 from power button trigger script
|
|
process.on('SIGUSR1',()=>{
|
|
console.log('[POWER] Received SIGUSR1 signal');
|
|
try{
|
|
if(mainWindow&&!mainWindow.isDestroyed()){
|
|
showPowerMenu();
|
|
}else{
|
|
console.log('[POWER] mainWindow not ready');
|
|
}
|
|
}catch(e){
|
|
console.error('[POWER] Error:',e.message);
|
|
}
|
|
});
|
|
|
|
app.on('certificate-error',(e,w,u,er,c,cb)=>{
|
|
e.preventDefault();
|
|
cb(true);
|
|
});
|
|
|
|
app.on('ready',createWindow);
|
|
|
|
app.on('will-quit',()=>{
|
|
globalShortcut.unregisterAll();
|
|
stopMasterTimer();
|
|
if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){
|
|
htmlKeyboardWindow.close();
|
|
}
|
|
});
|
|
|
|
app.on('window-all-closed',()=>{
|
|
if(process.platform!=='darwin')app.quit();
|
|
});
|
|
|
|
app.on('activate',()=>{
|
|
if(BrowserWindow.getAllWindows().length===0)createWindow();
|
|
});
|