diff --git a/slab/captutor/README.md b/slab/captutor/README.md index b4b80d772e..d013d58f42 100644 --- a/slab/captutor/README.md +++ b/slab/captutor/README.md @@ -32,3 +32,14 @@ Do not run multiple watchers against the same Chrome session/cache concurrently. Validate with `node --test slab/captutor/test/*.test.mjs`. Keep these modules and tests identical to their `captutor/` counterparts in Iris. + +## Nag-fighter + +`node slab/captutor/bin/install-nag-fighter.mjs` installs a persistent macOS +launch agent. It clears the known Chrome automation banner between takes, +remembers matches, and flags other dialogs. It never grants remote-debugging +consent. Stage Mode owns the desk while `stage-mode.json` exists, so the idle +watcher yields to the renderer's guards instead of racing their clicks. +Status, events and service logs live in `~/.local/share/captutor/nag-fighter/`. +The loop waits 1.5 seconds between native scans; scans add several seconds. +Use `node slab/captutor/bin/nag-fighter.mjs --once` for a single inspection. diff --git a/slab/captutor/bin/install-nag-fighter.mjs b/slab/captutor/bin/install-nag-fighter.mjs new file mode 100644 index 0000000000..011170229d --- /dev/null +++ b/slab/captutor/bin/install-nag-fighter.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import {existsSync,mkdirSync,writeFileSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {spawnSync,execFileSync} from 'node:child_process'; +if(process.platform!=='darwin')throw Error('Nag-fighter requires macOS Accessibility'); +const home=homedir(),label='studio.fuser.captutor-nag-fighter'; +const directory=join(home,'.local/share/captutor/nag-fighter'); +const agents=join(home,'Library/LaunchAgents'); +mkdirSync(directory,{recursive:true});mkdirSync(agents,{recursive:true}); +const plist=join(agents,label+'.plist'); +const xml=s=>s.replaceAll('&','&').replaceAll('<','<'); +const node=process.env.CAPTUTOR_NODE||(existsSync('/opt/homebrew/bin/node')?'/opt/homebrew/bin/node':process.execPath); +const script=fileURLToPath(new URL('./nag-fighter.mjs',import.meta.url)); +writeFileSync(plist,` + + +Label${label} +ProgramArguments${xml(node)}${xml(script)} +RunAtLoadKeepAlive +ThrottleInterval10 +StandardOutPath${xml(join(directory,'service.log'))} +StandardErrorPath${xml(join(directory,'service.log'))} +\n`); +const domain=`gui/${process.getuid()}`; +if(spawnSync('/bin/launchctl',['print',`${domain}/${label}`],{stdio:'ignore'}).status===0) + execFileSync('/bin/launchctl',['bootout',`${domain}/${label}`]); +execFileSync('/bin/launchctl',['bootstrap',domain,plist],{stdio:'inherit'}); +console.log('Nag-fighter installed: '+plist); diff --git a/slab/captutor/bin/nag-fighter.mjs b/slab/captutor/bin/nag-fighter.mjs new file mode 100644 index 0000000000..e00cd4470a --- /dev/null +++ b/slab/captutor/bin/nag-fighter.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// Keep known Chrome nags off the idle filming desk; Stage owns its active take. +import {existsSync,mkdirSync,writeFileSync,renameSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {join} from 'node:path'; +import {setTimeout as sleep} from 'node:timers/promises'; +import {createModalPolice} from '../lib/modal-police.mjs'; +const directory=join(homedir(),'.local/share/captutor/nag-fighter'); +const stageState=join(homedir(),'.local/share/captutor/stage-mode.json'); +const statePath=join(directory,'status.json'); +mkdirSync(directory,{recursive:true}); +const status=data=>{const tmp=statePath+'.tmp';writeFileSync(tmp,JSON.stringify({pid:process.pid,at:new Date().toISOString(),...data},null,2));renameSync(tmp,statePath);}; +const police=createModalPolice({directory,allowRemoteDebugging:false}); +const controller=new AbortController(); +for(const signal of ['SIGTERM','SIGINT'])process.on(signal,()=>controller.abort()); +do { + try { + if(existsSync(stageState))status({phase:'stage-owned'}); + else { + // Recheck ownership immediately before any action: a take can begin during AX scan. + const result=await police.check('preparing',{mayHandle:()=>!existsSync(stageState)&&!controller.signal.aborted}); + status({phase:'watching',...result}); + } + } catch(error) {status({phase:'blocked',error:error.stderr?.trim()||error.message});} + if(process.argv.includes('--once')||controller.signal.aborted)break; + try{await sleep(1500,undefined,{signal:controller.signal});}catch{break;} +} while(!controller.signal.aborted);