diff --git a/lith/server.mjs b/lith/server.mjs index 9c87877287..15eef6c0f9 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -1501,6 +1501,9 @@ if (!DEV) { reconcilingCredits=true; try { const paid=await import(pathToFileURL(join(SYSTEM,"backend","easel-paid-credits.mjs")).href); + const {connect}=await import(pathToFileURL(join(SYSTEM,"backend","database.mjs")).href); + const {speechBilling}=await import(pathToFileURL(join(SYSTEM,"backend","whistlegraph-speech-billing.mjs")).href); + await speechBilling((await connect()).db).reconcile(); const count=await paid.withWallets(w=>paid.reconcilePaidHolds(w)); if(count)console.log("[lith] recovered credit holds:",count); } catch { console.error("[lith] credit reconciliation failed; will retry"); } diff --git a/system/backend/account-deletion.mjs b/system/backend/account-deletion.mjs index 77266134ea..9e18e2f284 100644 --- a/system/backend/account-deletion.mjs +++ b/system/backend/account-deletion.mjs @@ -141,6 +141,7 @@ const EXPORT = [ // tokens, signed payloads or mint capabilities. An allowlist also keeps // future provider credentials out of this download. const PRIVATE_EXPORT = [ + ["whistlegraph-speech-requests", "user", ["_id", "braincells", "charged", "free", "paid", "status", "startedAt", "finishedAt"]], ["whistlegraph-iap-accounts", "_id", ["createdAt"]], ["whistlegraph-iap-purchases", "user", [ "_id", "transactionId", "productId", "credits", "environment", "createdAt", @@ -331,6 +332,7 @@ export async function handleQuarantined(db, handle, now = new Date()) { // Records keyed to the account that are deleted outright. // [collection, query(sub, ctx)] const DELETE = [ + ["whistlegraph-speech-requests", (sub) => ({ user: sub })], ["paintings", (sub) => ({ user: sub })], ["pieces", (sub) => ({ user: sub })], ["moods", (sub) => ({ user: sub })], diff --git a/system/backend/whistlegraph-speech-billing.mjs b/system/backend/whistlegraph-speech-billing.mjs new file mode 100644 index 0000000000..ea8d439120 --- /dev/null +++ b/system/backend/whistlegraph-speech-billing.mjs @@ -0,0 +1,60 @@ +import {createHash,randomUUID} from 'node:crypto'; +import {DAILY_TOKEN_BUDGET,dayKey} from './ai-budget.mjs'; +import {BRAINCELLS_PER_USD,INFERENCE_MARKUP,reserve,settle} from './easel-paid-credits.mjs'; +// https://developers.openai.com/api/docs/models/whisper-1 — $0.006/minute. +export const WHISPER_USD_PER_MINUTE=0.006; +export const speechCost=ms=>Math.ceil(Math.round(ms/60000*WHISPER_USD_PER_MINUTE*BRAINCELLS_PER_USD*INFERENCE_MARKUP*1e6)/1e6); +const fail=(status,message)=>Object.assign(Error(message),{status}); +const uuid=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +export const speechRequestKey=(user,id)=>createHash('sha256').update(user+'\0'+id.toLowerCase()).digest('hex'); +const walletIn=(db,session)=>({ + updateOne:(filter,update,options={})=>db.collection('ac-credit-wallets').updateOne(filter,update,{...options,session}), +}); +export function speechBilling(db,{now=()=>new Date(),limit=DAILY_TOKEN_BUDGET}={}) { + const receipts=db.collection('whistlegraph-speech-requests'),usage=db.collection('ai-usage'); + async function transaction(fn) { + const session=db.client.startSession(); + try{return await session.withTransaction(()=>fn(session),{readConcern:{level:'snapshot'},writeConcern:{w:'majority'}});} + finally{await session.endSession();} + } + async function finish(id,success) { + return transaction(async session=>{ + const r=await receipts.findOne({_id:id},{session}); + if(!r||r.status!=='pending')return false; + if(r.hold)await settle(r.hold,success?r.paid:0,walletIn(db,session),{now:now()}); + if(!success&&r.free)await usage.updateOne({_id:r.usageId},{$inc:{tokens:-r.free}},{session}); + if(success)await usage.updateOne({_id:r.usageId},{$inc:{asks:1},$set:{last:now(),lastModel:'openai/whisper-1'}},{session}); + await receipts.updateOne({_id:id,status:'pending'},{$set:{status:success?'complete':'failed',charged:success?r.braincells:0,finishedAt:now()}},{session}); + return true; + }); + } + async function reconcile(user) { + const query={status:'pending',expiresAt:{$lte:now()},...(user?{user}:{})}; + let count=0;for await(const r of receipts.find(query,{projection:{_id:1}}))if(await finish(r._id,false))count++; + return count; + } + async function begin({user,handle,requestId,audio,durationMs}) { + if(!uuid.test(requestId||''))throw fail(400,'Recording ID must be a UUID'); + const id=speechRequestKey(user,requestId),hash=createHash('sha256').update(audio).digest('hex'); + const braincells=speechCost(durationMs); + if(!Number.isSafeInteger(braincells)||braincells<1||durationMs>46000)throw fail(400,'Invalid recording duration'); + await reconcile(user); + const at=now(),day=dayKey(at),usageId=handle+':'+day,holdId=randomUUID(); + return transaction(async session=>{ + const previous=await receipts.findOne({_id:id},{session}); + if(previous) { + if(previous.hash!==hash)throw fail(409,'Recording ID was already used for different audio'); + throw fail(409,previous.status==='pending'?'Transcription is still processing':'Recording already processed; use the saved or device transcript'); + } + await usage.updateOne({_id:usageId},{$setOnInsert:{handle,day,first:at,tokens:0}},{upsert:true,session}); + const spent=await usage.findOne({_id:usageId},{session}); + const free=Math.min(braincells,Math.max(0,limit-(Number(spent.tokens)||0))),paid=braincells-free; + const hold=paid?await reserve(user,paid,walletIn(db,session),{id:holdId,now:at}):null; + if(paid&&!hold)throw fail(402,'Not enough braincells for OpenAI speech; using device speech'); + if(free)await usage.updateOne({_id:usageId},{$inc:{tokens:free}},{session}); + await receipts.insertOne({_id:id,user,hash,usageId,day,free,paid,hold,braincells,status:'pending',startedAt:at,expiresAt:new Date(+at+120000)},{session}); + return {id,braincells,free,paid,durationMs}; + }); + } + return {begin,finish,reconcile}; +} diff --git a/system/backend/whistlegraph-whisper.mjs b/system/backend/whistlegraph-whisper.mjs new file mode 100644 index 0000000000..a5bc4159bf --- /dev/null +++ b/system/backend/whistlegraph-whisper.mjs @@ -0,0 +1,39 @@ +// Bounded Whisper upload; audio and transcripts are never written to disk. +const fail=(status,message)=>Object.assign(Error(message),{status}); +export function wavInput(input) { + if(typeof input?.audio!=='string'||input.audio.length>2_000_000||input.audio.length%4||!/^[A-Za-z0-9+/]*={0,2}$/.test(input.audio))throw fail(400,'Invalid recording'); + const b=Buffer.from(input.audio,'base64'); + if(b.length<46||b.toString('ascii',0,4)!=='RIFF'||b.toString('ascii',8,12)!=='WAVE'|| + b.toString('ascii',12,16)!=='fmt '||b.readUInt32LE(16)!==16||b.readUInt16LE(20)!==1|| + b.readUInt16LE(22)!==1||b.readUInt32LE(24)!==16000||b.readUInt32LE(28)!==32000|| + b.readUInt16LE(32)!==2||b.readUInt16LE(34)!==16||b.toString('ascii',36,40)!=='data'|| + b.readUInt32LE(4)!==b.length-8||b.readUInt32LE(40)!==b.length-44||(b.length-44)%2)throw fail(400,'Expected mono 16 kHz PCM WAV'); + const durationMs=(b.length-44)/32; + if(durationMs<100||durationMs>46000)throw fail(400,'Recording must be under 46 seconds'); + return {audio:b,durationMs}; +} +export function createTranscriber({apiKey,fetch=globalThis.fetch}={}) { + let active=0; + return async input=>{ + if(!apiKey)throw fail(503,'Speech transcription is unavailable'); + const {audio,durationMs}=wavInput(input); + if(active>=2)throw fail(429,'Speech transcription is busy'); + active++; + try { + const form=new FormData();form.set('file',new Blob([audio],{type:'audio/wav'}),'recording.wav'); + form.set('model','whisper-1');form.set('language','en');form.set('response_format','verbose_json'); + form.append('timestamp_granularities[]','word'); + const start=performance.now(); + const response=await fetch('https://api.openai.com/v1/audio/transcriptions',{method:'POST',headers:{Authorization:'Bearer '+apiKey},body:form,signal:AbortSignal.timeout(12000)}); + if(!response.ok)throw fail(502,'Speech transcription failed'); + const result=await response.json(); + if(typeof result.text!=='string'||result.text.length>12000||!Array.isArray(result.words)||result.words.length>256)throw fail(502,'Invalid speech response'); + const words=result.words.map(w=>{ + if(typeof w.word!=='string'||w.word.length>2000||!Number.isFinite(w.start)||!Number.isFinite(w.end)||w.start<0||w.enddurationMs+250)throw fail(502,'Invalid word timing'); + return {text:w.word,atMs:Math.round(w.start*1000),durationMs:Math.round((w.end-w.start)*1000)}; + }); + return {transcript:result.text.trim(),words,provider:'openai',model:'whisper-1',elapsedMs:Math.round(performance.now()-start)}; + }catch(error){if(error.status)throw error;throw fail(502,'Speech transcription unavailable');} + finally{active--;} + }; +} diff --git a/system/netlify/functions/whistlegraph-transcribe.mjs b/system/netlify/functions/whistlegraph-transcribe.mjs new file mode 100644 index 0000000000..5db863258d --- /dev/null +++ b/system/netlify/functions/whistlegraph-transcribe.mjs @@ -0,0 +1,52 @@ +import {createHash} from 'node:crypto'; +import {wavInput,createTranscriber} from '../../backend/whistlegraph-whisper.mjs'; +import {speechBilling,speechRequestKey} from '../../backend/whistlegraph-speech-billing.mjs'; +const reply=(statusCode,value)=>({statusCode,headers:{'Content-Type':'application/json','Cache-Control':'private, no-store','Access-Control-Allow-Origin':'*','Access-Control-Allow-Methods':'POST, OPTIONS','Access-Control-Allow-Headers':'Authorization,Content-Type'},body:JSON.stringify(value)}); +export function createHandler({authorize,getHandleOrEmail,billing,transcribe,enabled=true,now=Date.now}) { + // Retry results live only in memory for one minute. Durable receipts contain + // cost and an audio digest, never recordings, transcripts or bearer tokens. + const retry=new Map();let active=0; + return async event=>{ + if(event.httpMethod==='OPTIONS')return reply(204,null); + if(event.httpMethod!=='POST')return reply(405,{error:'POST only'}); + if(!event.headers?.authorization)return reply(401,{error:'Sign in for OpenAI speech'}); + try { + const user=await authorize(event.headers); + if(!user?.sub)return reply(401,{error:'Sign in again for OpenAI speech'}); + if(user.email_verified!==true)return reply(403,{error:'Verify your email for OpenAI speech'}); + if(!enabled)return reply(503,{error:'OpenAI speech is unavailable'}); + const handle=await getHandleOrEmail(user.sub); + if(typeof handle!=='string'||!handle.startsWith('@'))return reply(403,{error:'Choose an AC handle first'}); + if(typeof event.body!=='string'||event.body.length>2_001_000)return reply(413,{error:'Recording too large'}); + let input;try {input=JSON.parse(event.body);}catch{return reply(400,{error:'Invalid recording'});} + if(!input||typeof input!=='object'||Array.isArray(input)||typeof input.requestId!=='string'||!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(input.requestId))return reply(400,{error:'Recording ID must be a UUID'}); + const {audio,durationMs}=wavInput(input),id=speechRequestKey(user.sub,input.requestId); + const hash=createHash('sha256').update(audio).digest('hex'); + for(const [key,item] of retry)if(item.until<=now())retry.delete(key); + const cached=retry.get(id);if(cached){if(cached.hash!==hash)return reply(409,{error:'Recording ID already used'});return reply(200,cached.value);} + if(active>=2)return reply(429,{error:'OpenAI speech is busy; using device speech'}); + active++; + try { + const receipt=await billing.begin({user:user.sub,handle:handle.slice(1),requestId:input.requestId,audio,durationMs}); + let result; + try {result=await transcribe(input);} + catch(error){await billing.finish(receipt.id,false);throw error;} + // If accounting cannot complete, recovery refunds the hold. Do not + // retry the provider or pretend the user received a successful result. + if(!await billing.finish(receipt.id,true))throw Object.assign(Error('Speech expired; using device speech'),{status:503}); + const value={...result,billing:{braincells:receipt.braincells,free:receipt.free,paid:receipt.paid,durationMs}}; + if(retry.size>=100)retry.delete(retry.keys().next().value); + retry.set(id,{hash,value,until:now()+60000});return reply(200,value); + } finally {active--;} + } catch(error){return reply(error.status||503,{error:error.status?error.message:'OpenAI speech unavailable; using device speech'});} + }; +} +let live; +export async function handler(event) { + if(!live)live=(async()=>{ + const [{authorize,getHandleOrEmail},{connect}]=await Promise.all([import('../../backend/authorization.mjs'),import('../../backend/database.mjs')]); + const {db}=await connect(),key=process.env.WHISTLEGRAPH_TRANSCRIPTION_KEY; + return createHandler({authorize,getHandleOrEmail,billing:speechBilling(db),transcribe:createTranscriber({apiKey:key}),enabled:!!key}); + })().catch(error=>{live=null;throw error;}); + try{return await(await live)(event);}catch{return reply(503,{error:'OpenAI speech unavailable'});} +} diff --git a/system/public/privacy-policy.html b/system/public/privacy-policy.html index 24c316f382..5adff18f63 100644 --- a/system/public/privacy-policy.html +++ b/system/public/privacy-policy.html @@ -59,7 +59,7 @@ AI creation requires your permission. Requests, relevant source and version history, drawings and their stroke timing, generated-preview images used for visual review, and sound measurements needed for a request are sent through Aesthetic Computer to OpenRouter and your selected model provider. Available providers include DeepSeek, Moonshot AI, Alibaba/Qwen, MiniMax, and Z.ai. Eligible personal-model accounts can also use Anthropic or OpenAI through our relay.

- Cloud speech and cloud narration each require separate permission. Cloud speech sends microphone audio to OpenAI for transcription, directly or through our relay. Cloud narration sends caption text through Aesthetic Computer to ElevenLabs. Without those permissions, speech recognition and narration use the device when available. Typed input remains available. + Cloud speech and cloud narration each require separate permission. Whisper cloud speech sends each finished recording through AC to OpenAI for transcription and word timing. It uses 40 braincells per recorded second, drawn from the daily allowance first and then purchased credits. Failed transcriptions are refunded. AC stores billing receipts and an audio digest to prevent duplicate charges, but does not store the recording or transcript for this service. A retry result stays in server memory for up to one minute. Earlier personal builds may also use live OpenAI transcription. Cloud narration sends caption text through Aesthetic Computer to ElevenLabs. Without those permissions, speech recognition and narration use the device when available. Typed input remains available.

You can withdraw these permissions in Whistlegraph's AI & privacy settings. This stops future transfers for the disabled feature; it cannot recall data already sent. Account deletion removes the drafts and history we store for your account. Provider processing and retention follow their own policies, including OpenRouter, OpenAI, Anthropic, and ElevenLabs. diff --git a/system/tests/whistlegraph-speech.test.mjs b/system/tests/whistlegraph-speech.test.mjs new file mode 100644 index 0000000000..d74494fceb --- /dev/null +++ b/system/tests/whistlegraph-speech.test.mjs @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {speechCost,speechBilling} from '../backend/whistlegraph-speech-billing.mjs'; +import {wavInput,createTranscriber} from '../backend/whistlegraph-whisper.mjs'; +import {createHandler} from '../netlify/functions/whistlegraph-transcribe.mjs'; +function wav(ms=1000) { + const b=Buffer.alloc(44+32*ms);b.write('RIFF');b.writeUInt32LE(b.length-8,4);b.write('WAVEfmt ',8);b.writeUInt32LE(16,16);b.writeUInt16LE(1,20);b.writeUInt16LE(1,22);b.writeUInt32LE(16000,24);b.writeUInt32LE(32000,28);b.writeUInt16LE(2,32);b.writeUInt16LE(16,34);b.write('data',36);b.writeUInt32LE(b.length-44,40);return b; +} +const event=(id=randomUUID())=>({httpMethod:'POST',headers:{authorization:'Bearer test'},body:JSON.stringify({requestId:id,audio:wav().toString('base64')})}); +test('Whisper uses actual bounded PCM duration and the published cost conversion',()=>{ + assert.equal(speechCost(1000),40);assert.equal(speechCost(8000),320);assert.equal(speechCost(45000),1800); + assert.equal(wavInput({audio:wav(1100).toString('base64')}).durationMs,1100); + assert.throws(()=>wavInput({audio:wav(47000).toString('base64')}),{status:400}); + const bad=wav();bad.writeUInt32LE(48000,24);assert.throws(()=>wavInput({audio:bad.toString('base64')}),{status:400}); +}); +test('authenticated replay returns the same result without another reservation or provider call',async()=>{ + let starts=0,calls=0,finishes=0; + const handler=createHandler({authorize:async()=>({sub:'u',email_verified:true}),getHandleOrEmail:async()=> '@fifi',billing:{begin:async()=>{starts++;return {id:'r',braincells:40,free:40,paid:0}},finish:async(_,ok)=>{assert.equal(ok,true);finishes++;return true;}},transcribe:async()=>{calls++;return {transcript:'hello',words:[]}}}); + const input=event();assert.equal((await handler(input)).statusCode,200);assert.equal((await handler(input)).statusCode,200); + assert.deepEqual([starts,calls,finishes],[1,1,1]); + const changed={...input,body:JSON.stringify({...JSON.parse(input.body),audio:wav(2000).toString('base64')})};assert.equal((await handler(changed)).statusCode,409); + assert.equal((await handler({...input,headers:{}})).statusCode,401); +}); +test('provider failure refunds and unverified users never reserve or upload audio',async()=>{ + let verified=true,refunded=0,calls=0; + const handler=createHandler({authorize:async()=>({sub:'u',email_verified:verified}),getHandleOrEmail:async()=> '@fifi',billing:{begin:async()=>({id:'r'}),finish:async(_,ok)=>{assert.equal(ok,false);refunded++;return true}},transcribe:async()=>{calls++;throw Object.assign(Error('Provider failed'),{status:502});}}); + assert.equal((await handler(event())).statusCode,502);assert.equal(refunded,1); + verified=false;assert.equal((await handler(event())).statusCode,403);assert.equal(calls,1); +}); +test('Whisper request preserves measured word timing',async()=>{ + const transcribe=createTranscriber({apiKey:'test',fetch:async(url,options)=>{ + assert.equal(options.body.get('model'),'whisper-1');assert.equal(options.body.get('timestamp_granularities[]'),'word'); + return {ok:true,json:async()=>({text:'Hello',words:[{word:'Hello',start:0.1,end:0.5}]})}; + }}); + assert.deepEqual((await transcribe({audio:wav().toString('base64')})).words,[{text:'Hello',atMs:100,durationMs:400}]); +}); +// Explicit integration mode, using only fresh, randomly named collections. +// Never read or mutate a real account, wallet or usage document. +test('Mongo transactions fence concurrent spending, replay, failure and crash recovery',{skip:process.env.WHISPER_BILLING_INTEGRATION!=='1'},async()=>{ + const {connect,closePool}=await import('../backend/database.mjs');const {db}=await connect(); + const prefix='test-whisper-'+randomUUID()+'-',names=new Set(); + const isolated={client:db.client,collection(name){names.add(prefix+name);return db.collection(prefix+name);}}; + let clock=new Date('2026-10-05T12:00:00Z');const billing=speechBilling(isolated,{now:()=>clock,limit:60}); + const request=(user='test-user')=>({user,handle:user,requestId:randomUUID(),audio:wav(),durationMs:1000}); + try { + // Materialize the three isolated collections before transactional DDL. + for(const name of ['ai-usage','ac-credit-wallets','whistlegraph-speech-requests'])await isolated.collection(name).insertOne({_id:'fixture'}); + const wallets=isolated.collection('ac-credit-wallets'),usage=isolated.collection('ai-usage'),receipts=isolated.collection('whistlegraph-speech-requests'); + await wallets.insertOne({_id:'test-user',balance:100}); + const a=request(),first=await billing.begin(a);assert.equal(first.free,40);assert.equal(first.paid,0); + await assert.rejects(billing.begin(a),{status:409});await billing.finish(first.id,true);assert.equal(await billing.finish(first.id,false),false); + const second=await billing.begin(request());assert.equal(second.free,20);assert.equal(second.paid,20); + assert.equal((await wallets.findOne({_id:'test-user'})).balance,80);await billing.finish(second.id,false); + assert.equal((await wallets.findOne({_id:'test-user'})).balance,100);assert.equal((await usage.findOne({_id:'test-user:2026-10-05'})).tokens,40); + const third=await billing.begin(request());await billing.finish(third.id,true); + assert.equal((await wallets.findOne({_id:'test-user'})).balance,80); + const stale=await billing.begin(request());clock=new Date(+clock+121000);assert.equal(await billing.reconcile(),1); + assert.equal((await wallets.findOne({_id:'test-user'})).balance,80);assert.equal(await billing.finish(stale.id,true),false); + const concurrent=await Promise.allSettled([billing.begin(request('race')),billing.begin(request('race'))]); + assert.equal(concurrent.filter(v=>v.status==='fulfilled').length,1);assert.equal(concurrent.find(v=>v.status==='rejected').reason.status,402); + const doc=await receipts.findOne({_id:first.id});assert.equal(doc.charged,40);assert.ok(!('audio' in doc));assert.ok(!('transcript' in doc)); + const overspend=request('no-money');await billing.begin(overspend);await assert.rejects(billing.begin(request('no-money')),{status:402}); + } finally {for(const name of names)await db.collection(name).drop();await closePool();} +}); -- 2.51.2 From efd08319a34359188498dced4655ea3bbefd6c67 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 5 Oct 2026 20:31:26 -0700 Subject: [PATCH 2/2] Preserve neo working source for fleet reconciliation --- aesel/src/backends.mjs | 5 + aesel/src/desktop-session.mjs | 6 +- aesel/src/harness-contract.mjs | 4 +- aesel/src/provider-preferences.mjs | 2 +- aesel/src/remote-server.mjs | 69 ++ captutor/bin/bake-fuser-mesh.swift | 156 ++++ fedac/native/docs/controller-playground.md | 43 + fedac/native/pieces/bunny-hop.mjs | 196 +++++ fedac/native/pieces/prompt.mjs | 12 +- fedac/native/pieces/starfighter.mjs | 296 +++++++ fedac/native/src/js-bindings.c | 2 + fedac/native/src/raycast-js.h | 46 ++ fedac/native/src/raycast.h | 56 ++ fedac/native/tests/bunny-hop.test.mjs | 10 + fedac/native/tests/raycast.c | 22 + fedac/native/tests/starfighter.test.mjs | 23 + fuser-gun/.gitignore | 2 + fuser-gun/README.md | 83 ++ fuser-gun/fixtures/areaware.json | 67 ++ fuser-gun/fixtures/oliviarodrigo.json | 92 +++ fuser-gun/ig-gun.mjs | 737 ++++++++++++++++++ fuser-gun/public-search.mjs | 98 +++ fuser-gun/public-search.test.mjs | 76 ++ help/relay/README.md | 80 ++ help/relay/aesel-relay.service | 26 + help/relay/deploy.sh | 14 + help/relay/service.mjs | 173 ++++ help/relay/service.test.mjs | 67 ++ slab/README.md | 21 +- slab/bin/claude-prompt-log.sh | 2 +- slab/bin/claude-sleep | 90 ++- slab/bin/claude-sleep-schedule.sh | 4 +- slab/bin/claude-stop.sh | 11 +- slab/bin/slab-menubar.py | 5 +- .../Sources/SlabMenubar/AppDelegate.swift | 37 +- .../SlabMenubar/DeskflowSpatialNav.swift | 54 +- .../SlabMenubar/DisplayLayoutWindow.swift | 359 +++++++++ .../Sources/SlabMenubar/MenuBuilder.swift | 5 +- .../Sources/SlabMenubar/Paths.swift | 1 + .../Sources/SlabMenubar/StateSnapshot.swift | 2 + .../Sources/SlabMenubar/main.swift | 6 + slab/test/sleep-control.test.mjs | 153 ++++ .../backend/whistlegraph-speech-billing.mjs | 5 + .../functions/whistlegraph-transcribe.mjs | 3 +- system/tests/account-deletion.test.mjs | 16 + system/tests/whistlegraph-speech.test.mjs | 3 +- toolchain/fleet/DISPLAYS.md | 126 +++ toolchain/fleet/deskflow-seat-host.py | 76 ++ toolchain/fleet/deskflow-seat-model.mjs | 80 ++ toolchain/fleet/deskflow-seat-model.test.mjs | 73 ++ toolchain/fleet/deskflow-seat.mjs | 162 ++++ toolchain/fleet/display-map.html | 135 ++++ toolchain/fleet/display-model.mjs | 106 +++ toolchain/fleet/display-model.test.mjs | 64 ++ toolchain/fleet/displays.mjs | 199 +++++ toolchain/fleet/fleet-mcp.mjs | 29 +- toolchain/fleet/normalize-machines.mjs | 4 +- toolchain/fleet/registry.mjs | 17 + toolchain/fleet/two-over-three.example.json | 12 + .../blueberry-wallpaper/Sources/main.swift | 56 +- .../blueberry-wallpaper/beat-listener.swift | 162 ++++ toolchain/macos/displays.swift | 243 ++++++ toolchain/macos/install-displays.sh | 13 + toolchain/youtube/icp-email-poster.mjs | 67 ++ toolchain/youtube/icp-review-playlists.sh | 37 + toolchain/youtube/mini-setup.sh | 25 + 66 files changed, 4860 insertions(+), 66 deletions(-) create mode 100644 aesel/src/remote-server.mjs create mode 100644 captutor/bin/bake-fuser-mesh.swift create mode 100644 fedac/native/pieces/bunny-hop.mjs create mode 100644 fedac/native/pieces/starfighter.mjs create mode 100644 fedac/native/src/raycast-js.h create mode 100644 fedac/native/src/raycast.h create mode 100644 fedac/native/tests/bunny-hop.test.mjs create mode 100644 fedac/native/tests/raycast.c create mode 100644 fedac/native/tests/starfighter.test.mjs create mode 100644 fuser-gun/.gitignore create mode 100644 fuser-gun/README.md create mode 100644 fuser-gun/fixtures/areaware.json create mode 100644 fuser-gun/fixtures/oliviarodrigo.json create mode 100644 fuser-gun/ig-gun.mjs create mode 100644 fuser-gun/public-search.mjs create mode 100644 fuser-gun/public-search.test.mjs create mode 100644 help/relay/README.md create mode 100644 help/relay/aesel-relay.service create mode 100755 help/relay/deploy.sh create mode 100644 help/relay/service.mjs create mode 100644 help/relay/service.test.mjs create mode 100644 slab/menubar-swift/Sources/SlabMenubar/DisplayLayoutWindow.swift create mode 100644 slab/test/sleep-control.test.mjs create mode 100644 toolchain/fleet/DISPLAYS.md create mode 100644 toolchain/fleet/deskflow-seat-host.py create mode 100644 toolchain/fleet/deskflow-seat-model.mjs create mode 100644 toolchain/fleet/deskflow-seat-model.test.mjs create mode 100644 toolchain/fleet/deskflow-seat.mjs create mode 100644 toolchain/fleet/display-map.html create mode 100644 toolchain/fleet/display-model.mjs create mode 100644 toolchain/fleet/display-model.test.mjs create mode 100644 toolchain/fleet/displays.mjs create mode 100644 toolchain/fleet/registry.mjs create mode 100644 toolchain/fleet/two-over-three.example.json create mode 100644 toolchain/macos/blueberry-wallpaper/beat-listener.swift create mode 100644 toolchain/macos/displays.swift create mode 100644 toolchain/macos/install-displays.sh create mode 100644 toolchain/youtube/icp-email-poster.mjs create mode 100755 toolchain/youtube/icp-review-playlists.sh create mode 100755 toolchain/youtube/mini-setup.sh diff --git a/aesel/src/backends.mjs b/aesel/src/backends.mjs index 10624f9619..508ebfdc09 100644 --- a/aesel/src/backends.mjs +++ b/aesel/src/backends.mjs @@ -14,6 +14,11 @@ import {DEFAULT_OPEN_MODEL,OPEN_MODELS} from './open-models.mjs'; import {deferredEngine} from './deferred-engine.mjs'; export const BACKENDS = { + relay: { + id: "relay", label: "personal relay", command: "", defaultModel: "", + modelSource: "help.aesthetic.computer", + Engine: deferredEngine(async()=> (await import('./remote-server.mjs')).RemoteServer), + }, claude: { id: "claude", label: "claude", diff --git a/aesel/src/desktop-session.mjs b/aesel/src/desktop-session.mjs index e355023f85..a78fc255df 100644 --- a/aesel/src/desktop-session.mjs +++ b/aesel/src/desktop-session.mjs @@ -10,10 +10,10 @@ export function desktopSnapshot({ cwd, backend, model, effort = "", live, state, live: { file: live.file, runtime: live.runtime?.id || live.runtime, channel: live.fallbackChannel || live.channel, genre: live.genre?.id || "piece" }, ui: Object.fromEntries(["entries", "input", "cursor", "history", "historyIndex", "queued", "medium", "livePaused", "showQr", "autoAllow", "scrollOffset", "pieceSlug", "media", "spend"].map((key) => [key, state[key]]).filter(([, value]) => value !== undefined)), options: { autopublish: options.autopublish, mouseEnabled: options.mouseEnabled }, - engine: { threadId: engine.threadId || "", ...(Number.isSafeInteger(engine.turns) ? {turns: engine.turns} : {}), ...(["ac", "open"].includes(backend) ? { messages: engine.messages || [], turns: engine.turns || 0 } : {}) }, handoff, archivedConversation }); + engine: { threadId: engine.threadId || "", ...(backend === "relay" ? {relayCursor: engine.cursor || 0} : {}), ...(Number.isSafeInteger(engine.turns) ? {turns: engine.turns} : {}), ...(["ac", "open"].includes(backend) ? { messages: engine.messages || [], turns: engine.turns || 0 } : {}) }, handoff, archivedConversation }); } export function validateDesktopSession(snapshot, cwd) { - if (snapshot?.schema !== 1 || snapshot.cwd !== resolve(cwd) || !["ac", "claude", "codex", "open"].includes(snapshot.backend)) throw new Error("Invalid desktop session or workspace mismatch."); + if (snapshot?.schema !== 1 || snapshot.cwd !== resolve(cwd) || !["ac", "claude", "codex", "open", "relay"].includes(snapshot.backend)) throw new Error("Invalid desktop session or workspace mismatch."); if (typeof snapshot.model !== "string" || typeof snapshot.live?.file !== "string" || !["mjs", "lisp", "lua"].includes(snapshot.live.runtime)) throw new Error("Invalid desktop piece or model."); if (snapshot.live.genre !== undefined && (!["piece", "nopaint"].includes(snapshot.live.genre) || (snapshot.live.genre === "nopaint" && snapshot.live.runtime !== "mjs"))) throw new Error("Invalid desktop piece genre."); if (snapshot.artifactId !== undefined && (!/^[a-f0-9-]{36}$/.test(snapshot.artifactId) || !Number.isSafeInteger(snapshot.artifactVersion) || snapshot.artifactVersion < 1)) throw new Error("Invalid saved artifact reference."); @@ -22,6 +22,7 @@ export function validateDesktopSession(snapshot, cwd) { const queuedLine = v => typeof v === "string" || (v && v.inbox === true && typeof v.from === "string" && typeof v.text === "string" && Object.keys(v).every(k => ["inbox", "from", "text"].includes(k))); if (!ui || !Array.isArray(ui.entries) || !ui.entries.every((e) => e && typeof e.id === "string" && typeof e.kind === "string" && typeof e.text === "string") || typeof ui.input !== "string" || !Array.isArray(ui.history) || !ui.history.every(v => typeof v === "string") || !Array.isArray(ui.queued) || !ui.queued.every(queuedLine) || !Number.isInteger(ui.cursor) || ui.cursor < 0 || ui.cursor > Array.from(ui.input).length) throw new Error("Invalid desktop transcript or draft."); if (typeof snapshot.engine?.threadId !== "string" || ((snapshot.backend === "ac" || snapshot.engine?.messages !== undefined) && (!Array.isArray(snapshot.engine.messages) || !Number.isSafeInteger(snapshot.engine.turns) || snapshot.engine.turns < 0))) throw new Error("Invalid desktop engine history."); + if (snapshot.engine.relayCursor !== undefined && (!Number.isSafeInteger(snapshot.engine.relayCursor) || snapshot.engine.relayCursor < 0)) throw new Error("Invalid relay cursor."); if (typeof snapshot.handoff !== "string" || !Array.isArray(snapshot.archivedConversation)) throw new Error("Invalid desktop handoff context."); const recovery = snapshot.recovery; if (recovery != null && (typeof recovery.text !== "string" || typeof recovery.from !== "string" || @@ -52,6 +53,7 @@ export async function writeDesktopSession(file, snapshot) { export function restoreDesktopEngine(engine, snapshot) { if (!snapshot) return; engine.threadId = snapshot.engine.threadId; + if (snapshot.backend === "relay") engine.cursor = snapshot.engine.relayCursor || 0; if (Number.isSafeInteger(snapshot.engine.turns)) engine.turns = snapshot.engine.turns; if (["ac", "open"].includes(snapshot.backend)) { engine.messages = clone(snapshot.engine.messages || []); engine.turns = snapshot.engine.turns || 0; } } diff --git a/aesel/src/harness-contract.mjs b/aesel/src/harness-contract.mjs index 72cd5a3c00..3160a4ead7 100644 --- a/aesel/src/harness-contract.mjs +++ b/aesel/src/harness-contract.mjs @@ -4,7 +4,7 @@ export const SETTINGS_TOOL={ description:'Read Aesel’s settings, open Settings, update provider/model/reasoning effort/auto-publish, or reify Aesel after editing its code. Reify reloads the TUI in place with its conversation, draft, queue and settings preserved. Changes and reification requested during a reply wait for that reply to finish; report queued actions as pending. Use these direct controls when the user asks, without shell commands or UI automation. This never edits the artwork.', inputSchema:{type:'object',properties:{ action:{type:'string',enum:['read','open','update','reify']}, - provider:{type:'string',enum:['ac','claude','codex','open']}, + provider:{type:'string',enum:['ac','claude','codex','open','relay']}, model:{type:'string',maxLength:160}, effort:{type:'string',enum:['','none','minimal','low','medium','high','xhigh','max','ultra']}, autopublish:{type:'boolean'}, @@ -19,7 +19,7 @@ export function validateSettingsRequest(value){ if(Object.keys(value).some(key=>!keys.includes(key))||!['read','open','update','reify'].includes(value.action))throw Error('Unsupported settings action'); if(value.action!=='update'&&Object.keys(value).length!==1)throw Error('Only update accepts setting values'); if(value.action==='update'&&Object.keys(value).length===1)throw Error('Choose a setting to update'); - if(value.provider!==undefined&&!['ac','claude','codex','open'].includes(value.provider))throw Error('Unknown provider'); + if(value.provider!==undefined&&!['ac','claude','codex','open','relay'].includes(value.provider))throw Error('Unknown provider'); if(value.model!==undefined&&(typeof value.model!=='string'||value.model.length>160||/[\x00-\x1f\x7f]/.test(value.model)))throw Error('Invalid model'); if(value.effort!==undefined&&!SETTINGS_TOOL.inputSchema.properties.effort.enum.includes(value.effort))throw Error('Invalid reasoning effort'); if(value.autopublish!==undefined&&typeof value.autopublish!=='boolean')throw Error('Invalid auto-publish setting'); diff --git a/aesel/src/provider-preferences.mjs b/aesel/src/provider-preferences.mjs index 664a4dd95e..54f79cc660 100644 --- a/aesel/src/provider-preferences.mjs +++ b/aesel/src/provider-preferences.mjs @@ -4,7 +4,7 @@ import {configDir} from './paths.mjs'; import {randomUUID} from 'node:crypto'; export const preferencesPath=()=>join(configDir(),'provider.json'); export function providerPreferences(value){ - if(!value||!['ac','claude','codex','open'].includes(value.backend))return null; + if(!value||!['ac','claude','codex','open','relay'].includes(value.backend))return null; if(typeof value.model!=='string'||value.model.length>200||/[\x00-\x1f]/.test(value.model))return null; if(!['','none','minimal','low','medium','high','xhigh','max','ultra'].includes(value.effort||''))return null; return {backend:value.backend,model:value.model,effort:value.effort||''}; diff --git a/aesel/src/remote-server.mjs b/aesel/src/remote-server.mjs new file mode 100644 index 0000000000..bb40d6c547 --- /dev/null +++ b/aesel/src/remote-server.mjs @@ -0,0 +1,69 @@ +// Closing a client detaches; accepted turns and their output stay on the relay. +import {EventEmitter} from 'node:events'; +import {randomUUID} from 'node:crypto'; +export class RemoteServer extends EventEmitter { + constructor({token,resumeThreadId='',model='',effort='',developerInstructions='', + url=process.env.AESEL_RELAY_URL||'https://help.aesthetic.computer', + provider=process.env.AESEL_RELAY_PROVIDER||'claude',fetch=globalThis.fetch,pollMs=500}={}) { + super();Object.assign(this,{token,model,effort,developerInstructions,provider,fetch,pollMs}); + this.url=url.replace(/\/$/,'');this.threadId=resumeThreadId;this.turnId=null; + this.closed=false;this.cursor=0;this.seenApprovals=new Set();this.abort=new AbortController(); + } + async call(path,body) { + const token=await this.token?.();if(!token)throw Error('Sign in to Aesthetic Computer to use your private relay'); + const response=await this.fetch(`${this.url}/api/aesel/sessions${path}`,{ + method:body===undefined?'GET':'POST',headers:{authorization:`Bearer ${token}`,'content-type':'application/json'}, + ...(body===undefined?{}:{body:JSON.stringify(body)}),signal:AbortSignal.any([this.abort.signal,AbortSignal.timeout(20000)])}); + const result=await response.json(); + if(!response.ok)throw Object.assign(Error(result.error||`Relay returned ${response.status}`),{status:response.status}); + return result; + } + async connect() { + const result=this.threadId?await this.call(`/${this.threadId}?after=0`):await this.call('',{ + provider:this.provider,model:this.model,effort:this.effort, + instructions:`You are in a remote workspace on help.aesthetic.computer. Client paths are context only and do not exist on this host.\n\n${this.developerInstructions}`}); + this.threadId=result.thread.id; + this.timer=setTimeout(()=>void this.poll(),0); + return {thread:{id:this.threadId,turns:[]},model:this.model}; + } + async poll() { + if(this.closed)return; + try { + const data=await this.call(`/${this.threadId}?after=${this.cursor}`); + for(const entry of data.events){ + if(entry.seq<=this.cursor)continue;this.cursor=entry.seq; + if(entry.type==='notification'){ + if(entry.value.method==='turn/started')this.turnId=entry.value.params?.turn?.id; + if(entry.value.method==='turn/completed')this.turnId=null; + this.emit('notification',entry.value); + }else if(entry.type==='fatal')this.emit('notification',{method:'turn/completed',params:{turn:{id:this.turnId,status:'failed',error:entry.value}}}); + } + for(const request of data.pending)if(!this.seenApprovals.has(String(request.id))){ + this.seenApprovals.add(String(request.id));this.emit('request',request); + } + this.failed=false; + }catch(error){ + if(this.closed)return; + if(error.status===401||error.status===403){this.emit('fatal',error);this.close();return;} + if(!this.failed)this.emit('log','Relay disconnected; reconnecting to saved turn');this.failed=true; + } + if(!this.closed)this.timer=setTimeout(()=>void this.poll(),this.failed?2000:this.pollMs); + } + async startTurn(text,{images=[],requestId=randomUUID()}={}) { + const input={text,images,requestId}; + let request; + try {request=await this.call(`/${this.threadId}/turn`,input);} + catch(error){ + if(this.closed || (error.status && error.status<500))throw error; + request=await this.call(`/${this.threadId}/turn`,input); + } + return {turn:{id:request.requestId,status:request.status==='running'?'inProgress':request.status}}; + } + respond(id,result){void this.call(`/${this.threadId}/respond`,{id,result}).catch(error=>{ + this.seenApprovals.delete(String(id));this.emit('log',error.message); + });} + reject(id){this.respond(id,{decision:'decline'});} + async interrupt(){return this.call(`/${this.threadId}/interrupt`,{});} + async newThread(){clearTimeout(this.timer);this.threadId='';this.cursor=0;this.seenApprovals.clear();return this.connect();} + close(){this.closed=true;clearTimeout(this.timer);this.abort.abort();} +} diff --git a/captutor/bin/bake-fuser-mesh.swift b/captutor/bin/bake-fuser-mesh.swift new file mode 100644 index 0000000000..302f612f15 --- /dev/null +++ b/captutor/bin/bake-fuser-mesh.swift @@ -0,0 +1,156 @@ +// Bake the canonical twelve-node Fuser mark — the same smooth-union field +// render-fuser-metaballs.swift ray-marches — into an indexed OBJ mesh with +// vertex normals, so SceneKit-based desktops can carry the mark as real +// geometry. Marching tetrahedra over a regular grid; vertices are shared along +// grid edges, so the surface is watertight and shades smoothly. +// +// swiftc -O bake-fuser-mesh.swift -o bake-fuser-mesh +// ./bake-fuser-mesh ../assets/fuser-mesh.obj [--step 0.02] + +import Foundation +import simd + +guard CommandLine.arguments.count >= 2 else { + fputs("usage: bake-fuser-mesh [--step 0.02]\n", stderr) + exit(2) +} +let outputPath = CommandLine.arguments[1] +let arguments = Array(CommandLine.arguments.dropFirst(2)) +let step: Float = { + guard let i = arguments.firstIndex(of: "--step"), arguments.indices.contains(i + 1), + let v = Float(arguments[i + 1]), v > 0 else { return 0.02 } + return v +}() + +// Centers from captutor/assets/fuser-mark.svg (24×24 viewBox), normalized as +// in render-fuser-metaballs.swift, including its alternating depth. +let svgCenters: [(Float, Float)] = [ + (15.1507, 2.5838), (21.4201, 2.5838), (8.8818, 2.5838), + (8.8818, 8.86312), (2.58145, 8.83282), (2.61171, 15.1369), + (2.61171, 21.4162), (8.8818, 21.4162), (15.1507, 21.4162), + (15.1507, 15.1369), (21.4201, 15.1369), (21.4201, 8.86312), +] +let centers: [SIMD3] = svgCenters.enumerated().map { index, point in + SIMD3((point.0 - 12) / 7.75, (12 - point.1) / 7.75, sin(Float(index) * 1.71) * 0.055) +} +let radius: Float = 0.355 +let blend: Float = 0.30 + +@inline(__always) func smoothMin(_ a: Float, _ b: Float, _ k: Float) -> Float { + let h = max(k - abs(a - b), 0) / k + return min(a, b) - h * h * k * 0.25 +} +@inline(__always) func field(_ p: SIMD3) -> Float { + var d = simd_length(p - centers[0]) - radius + for c in centers.dropFirst() { d = smoothMin(d, simd_length(p - c) - radius, blend) } + return d +} +func normal(_ p: SIMD3) -> SIMD3 { + let e: Float = 0.004 + let n = SIMD3(field(p + SIMD3(e, 0, 0)) - field(p - SIMD3(e, 0, 0)), + field(p + SIMD3(0, e, 0)) - field(p - SIMD3(0, e, 0)), + field(p + SIMD3(0, 0, e)) - field(p - SIMD3(0, 0, e))) + return simd_normalize(n) +} + +// Grid bounds: the mark plus its radius and blend, with a margin. +let lo = SIMD3(-1.75, -1.75, -0.55) +let hi = SIMD3(1.75, 1.75, 0.55) +let nx = Int(((hi.x - lo.x) / step).rounded(.up)) + 1 +let ny = Int(((hi.y - lo.y) / step).rounded(.up)) + 1 +let nz = Int(((hi.z - lo.z) / step).rounded(.up)) + 1 +func gridPoint(_ i: Int, _ j: Int, _ k: Int) -> SIMD3 { + SIMD3(lo.x + Float(i) * step, lo.y + Float(j) * step, lo.z + Float(k) * step) +} +func gridIndex(_ i: Int, _ j: Int, _ k: Int) -> Int { (i * ny + j) * nz + k } + +var values = [Float](repeating: 0, count: nx * ny * nz) +DispatchQueue.concurrentPerform(iterations: nx) { i in + for j in 0..] = [] +var triangles: [(Int, Int, Int)] = [] + +// Tetrahedra also cut cube face and body diagonals, so key on both corners. +func edgeKey(_ a: Int, _ b: Int) -> Int { a < b ? a &* (nx * ny * nz) &+ b : b &* (nx * ny * nz) &+ a } + +func vertex(_ a: Int, _ b: Int, _ pa: SIMD3, _ pb: SIMD3) -> Int { + let key = edgeKey(a, b) + if let existing = vertexForEdge[key] { return existing } + let va = values[a], vb = values[b] + let t = va / (va - vb) + positions.append(pa + (pb - pa) * t) + let index = positions.count - 1 + vertexForEdge[key] = index + return index +} + +// Six tetrahedra per cube around the 0–7 diagonal (corner bits: x=1, y=2, z=4). +let tets: [[Int]] = [[0, 1, 3, 7], [0, 3, 2, 7], [0, 2, 6, 7], [0, 6, 4, 7], [0, 4, 5, 7], [0, 5, 1, 7]] + +for i in 0..<(nx - 1) { + for j in 0..<(ny - 1) { + for k in 0..<(nz - 1) { + var corner = [Int](repeating: 0, count: 8) + var pt = [SIMD3](repeating: .zero, count: 8) + for c in 0..<8 { + let ci = i + (c & 1), cj = j + ((c >> 1) & 1), ck = k + ((c >> 2) & 1) + corner[c] = gridIndex(ci, cj, ck) + pt[c] = gridPoint(ci, cj, ck) + } + // Skip cubes fully inside or outside. + var inside = 0 + for c in 0..<8 where values[corner[c]] < 0 { inside += 1 } + if inside == 0 || inside == 8 { continue } + + for tet in tets { + let v = tet.map { corner[$0] } + let p = tet.map { pt[$0] } + var mask = 0 + for t in 0..<4 where values[v[t]] < 0 { mask |= 1 << t } + if mask == 0 || mask == 15 { continue } + func e(_ a: Int, _ b: Int) -> Int { vertex(v[a], v[b], p[a], p[b]) } + // Orientation is fixed afterwards from the field normal. + switch mask { + case 1, 14: triangles.append((e(0, 1), e(0, 2), e(0, 3))) + case 2, 13: triangles.append((e(1, 0), e(1, 3), e(1, 2))) + case 4, 11: triangles.append((e(2, 0), e(2, 1), e(2, 3))) + case 8, 7: triangles.append((e(3, 0), e(3, 2), e(3, 1))) + case 3, 12: + let a = e(0, 2), b = e(0, 3), c = e(1, 3), d = e(1, 2) + triangles.append((a, b, c)); triangles.append((a, c, d)) + case 5, 10: + let a = e(0, 1), b = e(0, 3), c = e(2, 3), d = e(2, 1) + triangles.append((a, b, c)); triangles.append((a, c, d)) + case 6, 9: + let a = e(0, 1), b = e(0, 2), c = e(3, 2), d = e(3, 1) + triangles.append((a, b, c)); triangles.append((a, c, d)) + default: break + } + } + } + } +} + +// Normals from the field; wind every triangle to face outward along them. +let normals = positions.map(normal) +var out = "# Fuser mark — twelve-node smooth-union surface, baked by captutor/bin/bake-fuser-mesh.swift\n" +out.reserveCapacity(positions.count * 60 + triangles.count * 30) +for p in positions { out += "v \(p.x) \(p.y) \(p.z)\n" } +for n in normals { out += "vn \(n.x) \(n.y) \(n.z)\n" } +out += "g fuser\n" +for (a, b, c) in triangles { + let faceNormal = simd_cross(positions[b] - positions[a], positions[c] - positions[a]) + let outward = simd_dot(faceNormal, normals[a] + normals[b] + normals[c]) >= 0 + let (x, y, z) = outward ? (a, b, c) : (a, c, b) + out += "f \(x + 1)//\(x + 1) \(y + 1)//\(y + 1) \(z + 1)//\(z + 1)\n" +} +try out.write(toFile: outputPath, atomically: true, encoding: .utf8) +print("✓ \(outputPath) · \(positions.count) vertices · \(triangles.count) triangles · step \(step)") diff --git a/fedac/native/docs/controller-playground.md b/fedac/native/docs/controller-playground.md index 8097f7b5c6..d4ecbde058 100644 --- a/fedac/native/docs/controller-playground.md +++ b/fedac/native/docs/controller-playground.md @@ -48,6 +48,49 @@ receiver over authenticated SSH; see [AC keyboard remote](../../../slab/menubar- for the Command–Option–L shortcut and per-host configuration. Helpers are installed separately; the image builders do not install these binaries yet. +## Starfighter + +`starfighter` automatically flies and fights in third person. A/RT fires, +B shields, X launches a missile, RB boosts, LB rolls, Y restarts, and Menu +switches autopilot. Manual input overrides autopilot for four seconds. +Keyboard: arrows/Space, B/X/Shift/L, R/P; M mutes immediately; Escape opens +prompt. Audio uses at most 16 sine voices for descending lasers, bass +explosions and a 96 BPM score. Rocks are visual geometry, without collisions. + +`api.raycast(shapes, count, uniforms, depth)` draws lit ellipsoids into the +native framebuffer. Reuse Float64Array shape/uniform buffers and a +Float32Array depth buffer. The layouts are documented in `src/raycast.h`; +the binding checks dimensions, lengths, finite values and overlapping inputs. +It retains backing buffers only during the call. At 640×360, the piece casts +320×180 environment rays and full-resolution ship rays, with per-pixel depth +compositing. Older runtimes use the adaptive JavaScript renderer. + +A 35-second live sample with audio measured 1–3 ms raycast time (median 2 ms), +56–60.2 FPS (median 60), and 1–9 active voices. The sample is evidence for this +device and scene, not a guaranteed frame rate. C tests cover intersections, +occlusion, clipping and framebuffer stride; live binding probes reject short, +wrong-size, nonfinite and overlapping arrays. Run the model/audio tests with +`node --test tests/starfighter.test.mjs` and the renderer test with +`cc -O2 -Wall -Wextra -Werror tests/raycast.c -lm -o /tmp/raycast-test`. + +The native prompt now uses GNU Unifont at 8×16 with 18-pixel line spacing. + +## Bunny Hop + +`bunny-hop` is a 3D platformer with a camera that follows and swings around +the route, lifting during jumps. Water shadows anchor turtles and trees; a +depth-tested bunny shadow marks its landing spot. Controls are D-pad plus A +only (arrows/Space on a keyboard); hold A for a higher hop. Autoplay starts +immediately, manual input takes over, and eight idle seconds resume the demo. +Visit twelve turtle backs to reach Fia's island. Falling returns the bunny to +its last turtle. A restarts after winning; the demo also restarts automatically. + +Orchard apples grow sequentially, ripen at seven seconds and fall at twelve. +Occasional birds land and shake ripe apples down early. Fruit lands on the +turtle shells and can be collected. The demo hops in place at orchard turtles. +Run the six gameplay/fruit checks with `node --test tests/bunny-hop.test.mjs`. +The piece needs the native `api.raycast` build installed during this session. + ## Pieces - `little-platformer`: side-scrolling physics at a fixed 120 Hz simulation diff --git a/fedac/native/pieces/bunny-hop.mjs b/fedac/native/pieces/bunny-hop.mjs new file mode 100644 index 0000000000..dab5895ad0 --- /dev/null +++ b/fedac/native/pieces/bunny-hop.mjs @@ -0,0 +1,196 @@ +// Bunny Hop, 26.10.04 +// A 3D bunny hops across friendly turtle backs to reach Fia's island. +import { savedWifi } from '../lib/saved-wifi.mjs'; +const connectSaved=savedWifi(),STEP=1/120; +const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); +export function createMeadow(){ + const turtles=Array.from({length:12},(_,i)=>({x:Math.sin(i*1.15)*29,z:(i+1)*66,r:26,top:8,visited:false})); + return {p:{x:0,y:9,z:0,vx:0,vy:0,vz:0,ground:true},turtles,time:0,checkpoint:-1,visited:0,apples:[],appleCount:0,nextApple:0,appleSerial:0,bird:null,nextBird:10,buffer:0,coyote:.1,events:[],won:false,splashes:0}; +} +function platforms(s){return [{x:0,z:0,r:44,top:9,index:-1},...s.turtles.map((t,i)=>({...t,index:i})),{x:0,z:858,r:47,top:9,index:12}];} +function orchardStep(s,dt){ + const p=s.p; + // Only one apple grows at a time; ripe and fallen apples may coexist. + if(s.time>=s.nextApple&&!s.apples.some(a=>!a.falling&&a.age<7)){ + const nearby=[0,3,6,9].filter(i=>Math.abs(s.turtles[i].z-p.z)<260); + const tree=nearby.sort((a,b)=>Math.abs(s.turtles[a].z-p.z)-Math.abs(s.turtles[b].z-p.z))[0]??9,t=s.turtles[tree]; + s.apples.push({id:s.appleSerial++,tree,x:t.x+8,z:t.z,y:48,age:0,vy:0,falling:false,ground:false});s.nextApple=s.time+7.5; + } + for(const a of s.apples){ + a.age+=dt; + if(a.age>12)a.falling=true; + if(a.falling&&!a.ground){ + a.vy-=95*dt;a.y+=a.vy*dt; + const t=s.turtles[a.tree]; + if(a.y<=t.top+3&&Math.hypot(a.x-t.x,a.z-t.z)=7&&Math.hypot(a.x-p.x,a.z-p.z)<13&&Math.abs(a.y-(p.y+12))<20){a.taken=true;s.appleCount++;s.events.push('apple');} + } + s.apples=s.apples.filter(a=>!a.taken&&a.y>-20&&a.age<35).slice(-10); + if(s.time>=s.nextBird&&!s.bird){ + const a=s.apples.find(a=>a.age>=7&&!a.falling); + if(a){s.bird={apple:a.id,time:0,x:a.x-110,y:a.y+38,z:a.z-15,shaken:false};s.nextBird=s.time+17;} + } + if(s.bird){ + const b=s.bird,a=s.apples.find(a=>a.id===b.apple);b.time+=dt; + if(a&&b.time<1.5){const blend=Math.min(1,b.time/1.5);b.x=a.x-110*(1-blend);b.y=53+30*(1-blend);b.z=a.z;} + else if(b.time<2.5){b.y=53+Math.sin(b.time*35)*2;if(a&&!b.shaken){a.falling=true;b.shaken=true;s.events.push('bird');}} + else{b.x+=65*dt;b.y+=24*dt;} + if(b.time>5)s.bird=null; + } +} +export function meadowStep(s,input,dt=STEP){ + const p=s.p;s.time+=dt;s.events=[]; + if(s.won)input={}; + s.buffer=input.jumpPressed?.13:Math.max(0,s.buffer-dt); + s.coyote=p.ground?.1:Math.max(0,s.coyote-dt); + if(s.buffer>0&&s.coyote>0){p.vy=83;p.ground=false;s.buffer=s.coyote=0;s.events.push('hop');} + let ax=input.x||0,az=input.z||0;const length=Math.hypot(ax,az);if(length>1){ax/=length;az/=length;} + const speed=78,accel=p.ground?450:280; + p.vx+=clamp(ax*speed-p.vx,-accel*dt,accel*dt);p.vz+=clamp(az*speed-p.vz,-accel*dt,accel*dt); + const old=p.y;p.x+=p.vx*dt;p.z+=p.vz*dt; + p.vy-=(input.jumpHeld&&p.vy>0?142:195)*dt;p.y+=p.vy*dt;p.ground=false; + for(const t of platforms(s))if(p.vy<=0&&old>=t.top-.1&&p.y<=t.top&&Math.hypot(p.x-t.x,p.z-t.z)=0&&t.index<12){ + s.checkpoint=t.index; + if(!s.turtles[t.index].visited){s.turtles[t.index].visited=true;s.visited++;s.events.push('turtle');if([0,3,6,9].includes(t.index))s.restUntil=s.time+12;} + } + if(t.index===12&&s.visited===12&&!s.won){s.won=true;s.events.push('win');} + break; + } + if(p.y<-32){ + const t=s.checkpoint<0?{x:0,z:0,top:9}:s.turtles[s.checkpoint]; + Object.assign(p,{x:t.x,y:t.top,z:t.z,vx:0,vy:0,vz:0,ground:true});s.buffer=0;s.coyote=.1;s.splashes++;s.events.push('splash'); + } + orchardStep(s,dt); +} +export function bunnyPilot(s){ + if(s.time<(s.restUntil||0)){const t=s.turtles[s.checkpoint],dx=t.x+8-s.p.x;return {x:Math.abs(dx)>2?clamp(dx*.05,-.2,.2):0,z:Math.abs(t.z-s.p.z)>2?clamp((t.z-s.p.z)*.05,-.2,.2):0,jumpPressed:s.p.ground,jumpHeld:false};} + const index=s.turtles.findIndex(t=>!t.visited),target=index<0?{x:0,z:858}:s.turtles[index],p=s.p; + const dx=target.x-p.x,dz=target.z-p.z,d=Math.hypot(dx,dz); + return {x:d>3?dx/d:0,z:d>3?dz/d:0,jumpPressed:p.ground&&!s.won,jumpHeld:true}; +} +let world=createMeadow(),pad={},previous=0,last=0,accum=0,helperAt=0,pendingJump=false; +let demo=true,manualUntil=0,sound=true,toneAt=0,musicAt=0,musicStep=0,winAt=0,frames=0,fps=60,fpsAt=0,camX=0,camZ=0,camYaw=.35,camHeight=185,rayMS=0; +const keys=new Set(),pressedKeys=new Set(),voices=[]; +function reader(system){system.pty2?.spawn('/bin/sh',['-c','exec /mnt/tools/ac-usb-controller'],40,10);helperAt=Date.now();} +function reset(){world=createMeadow();accum=0;camX=0;camZ=0;camYaw=.35;camHeight=185;winAt=0;} +export function boot({system}){system.startSSH?.();last=fpsAt=Date.now();try{pad=JSON.parse(system.readFile('/tmp/ac-controller.json')||'{}');}catch{}if(!pad.at||Date.now()-pad.at>1500)reader(system);} +function stop(){for(const v of voices)v.voice.kill?.(.025);voices.length=0;} +export function leave(){stop();} +function note(api,tone,duration,volume,now){if(!sound||voices.length>=8)return;const voice=api.sound?.synth?.({type:'sine',tone,volume,duration,attack:.012,decay:duration*.85});if(voice)voices.push({voice,end:now+duration*1000});} +export function sim(api){ + connectSaved(api);const now=Date.now(),dt=clamp((now-last)/1000,0,.1);last=now;accum=Math.min(.1,accum+dt); + for(let i=voices.length-1;i>=0;i--)if(now>=voices[i].end)voices.splice(i,1); + try{pad=JSON.parse(api.system.readFile('/tmp/ac-controller.json')||'{}');}catch{pad={};} + if(!pad.at||now-pad.at>1500){pad={};if(now-helperAt>5000)reader(api.system);} + const b=pad.connected&&pad.ready?pad.buttons:0,pressed=b&~previous;previous=b; + let x=(b&0x800||keys.has('arrowright')?1:0)-(b&0x400||keys.has('arrowleft')?1:0),z=(b&0x100||keys.has('arrowup')?1:0)-(b&0x200||keys.has('arrowdown')?1:0); + pendingJump ||= !!(pressed&0x10)||pressedKeys.has('space');pressedKeys.clear(); + const input={x:x*Math.cos(camYaw)+z*Math.sin(camYaw),z:z*Math.cos(camYaw)-x*Math.sin(camYaw),jumpHeld:!!(b&0x10)||keys.has('space')}; + if(x||z||input.jumpHeld||pendingJump)manualUntil=now+8000; + demo=now>=manualUntil;const auto=demo; + if(world.won){if(!winAt)winAt=now;if(now-winAt>5000||pendingJump)reset();} + let events=[];while(accum>=STEP){meadowStep(world,auto?bunnyPilot(world):{...input,jumpPressed:pendingJump},STEP);events.push(...world.events);pendingJump=false;accum-=STEP;} + if(sound&&events.length&&now-toneAt>70){toneAt=now;const e=['win','apple','bird','turtle','splash','hop'].find(e=>events.includes(e));note(api,{win:262,apple:247,bird:294,turtle:196,splash:55,hop:110}[e],.24,.065,now);} + if(sound&&now>=musicAt){musicAt=now+410;const melody=[0,7,12,7,4,7,9,4,0,5,9,5,2,5,7,2];note(api,130.81*2**(melody[musicStep%16]/12),.35,.02,now);if(musicStep%4===0)note(api,musicStep%16<8?65.41:43.65,1.2,.035,now);musicStep++;} + camYaw+=(.18+.52*(.5+.5*Math.sin(world.p.z*.009-.6))-camYaw)*(1-Math.exp(-1.5*dt)); + camHeight+=(175+24*Math.sin(world.p.z*.007)+Math.max(0,world.p.y-9)*.35-camHeight)*(1-Math.exp(-2.5*dt)); + camX+=(world.p.x-camX)*(1-Math.exp(-6*dt));camZ+=(world.p.z-camZ)*(1-Math.exp(-7*dt)); +} +const shapes=new Float64Array(128*16),uniforms=new Float64Array(21);let depth; +export function paint(api){ + const {screen,wipe,ink,box,circle,line,write}=api,w=screen.width,h=screen.height,s=world,p=s.p; + const text=(str,x,y,size=1)=>write(str,{x:Math.round(x),y:Math.round(y),size,font:'font_1'}); + // A rail-like camera eases around the route and lifts to frame a jump. + const ox=Math.sin(camYaw)*240,oz=Math.cos(camYaw)*240,dy=camHeight-10,length=Math.hypot(240,dy); + const camera={x:camX-ox,y:camHeight,z:camZ-oz}; + const forward={x:ox/length,y:-dy/length,z:oz/length},right={x:Math.cos(camYaw),y:0,z:-Math.sin(camYaw)},up={x:-forward.y*Math.sin(camYaw),y:240/length,z:-forward.y*Math.cos(camYaw)}; + const f=w*.95,cx=w*.5,cy=h*.58; + const view=(x,y,z)=>{const dx=x-camera.x,dy=y-camera.y,dz=z-camera.z;return {x:dx*right.x+dz*right.z,y:dx*up.x+dy*up.y+dz*up.z,z:dx*forward.x+dy*forward.y+dz*forward.z};}; + const project=(x,y,z)=>{const v=view(x,y,z);if(v.z<2)return null;return {x:cx+v.x*f/v.z,y:cy-v.y*f/v.z,z:v.z,k:f/v.z,vx:v.x,vy:v.y};}; + wipe(85,161,166); + for(let i=0;i<50;i++){const wz=Math.floor(p.z/60)*60+(i%10)*60-120,wx=(Math.floor(i/10)-2)*155+Math.sin(s.time+i)*4,q=project(wx,-6,wz);if(q&&q.y>0&&q.y{const q=project(x,y,z);if(!q)return;const rx=r*q.k,ry=rx*.55;if(q.x+rx<0||q.x-rx>w||q.y+ry<0||q.y-ry>h)return;ink(...color);for(let row=-Math.ceil(ry);row<=ry;row+=2){const half=rx*Math.sqrt(Math.max(0,1-row*row/(ry*ry)));box(q.x-half,q.y+row,half*2,2);}}; + for(const t of s.turtles)if(t.z>p.z-100&&t.zp.z-90&&t.z{ + if(count>=128)return;const q=project(x,y,z);if(!q)return; + // Conservative projected bounds include camera pitch and ellipsoid depth. + const spanZ=Math.abs(forward.x)*rx+Math.abs(forward.y)*ry+Math.abs(forward.z)*rz,spanY=Math.abs(up.x)*rx+up.y*ry+Math.abs(up.z)*rz,spanX=Math.abs(right.x)*rx+Math.abs(right.z)*rz,near=Math.max(1,q.z-spanZ); + const sx=f*(spanX+Math.abs(q.vx)*spanZ/q.z)/near,viewY=q.vy,sy=f*(spanY+Math.abs(viewY)*spanZ/q.z)/near; + const viewRX=Math.hypot(right.x*rx,right.z*rz),viewRY=Math.hypot(up.x*rx,up.y*ry,up.z*rz),viewRZ=Math.hypot(forward.x*rx,forward.y*ry,forward.z*rz); + if(q.x+sx<0||q.x-sx>w||q.y+sy<0||q.y-sy>h)return; + // View-space ellipsoids are deliberately stylized rounded toy shapes. + shapes.set([q.vx,viewY,q.z,viewRX,viewRY,viewRZ,...color,0,0,+detail,Math.max(0,q.x-sx),Math.min(w,q.x+sx),Math.max(0,q.y-sy),Math.min(h,q.y+sy)],count++*16); + }; + for(const z of [0,858]){shape(0,-1,z,48,10,48,[148,183,108]);shape(0,-8,z,50,8,50,[185,146,99]);} + for(let i=0;ip.z+285)continue; + const x=t.x,z=t.z,wiggle=Math.sin(s.time*4+i)*2; + shape(x,0,z,26,8,26,t.visited?[128,182,102]:[69,135,94]); + shape(x,5,z,16,4,16,t.visited?[185,210,121]:[122,174,101]); + shape(x-13,3,z-10,8,3,8,[154,191,110]);shape(x+13,3,z+8,8,3,8,[154,191,110]); + shape(x,-1,z-30,9,6,10,[191,207,124]); + shape(x-4,3,z-37,1.7,2,1.5,[37,57,62]);shape(x+4,3,z-37,1.7,2,1.5,[37,57,62]); + for(const side of [-1,1]){shape(x+side*26,-3,z-14+wiggle,9,2.5,7,[158,190,103]);shape(x+side*24,-3,z+15-wiggle,8,2.5,7,[158,190,103]);} + } + // Orchard branches hang over the turtle route; fruit falls onto shells. + for(const i of [0,3,6,9]){ + const t=s.turtles[i];if(t.zp.z+285)continue; + const shake=s.bird&&s.bird.time>1.5&&s.bird.time<2.5&&Math.abs(s.bird.z-t.z)<3?Math.sin(s.time*40)*2:0; + shape(t.x+56,10,t.z,18,8,22,[155,178,108]); + shape(t.x+56,31,t.z,4,25,4,[151,104,80]); + shape(t.x+31+shake,47,t.z,28,3,4,[151,104,80]); + shape(t.x+52+shake,61,t.z,21,17,19,[95,151,91]); + shape(t.x+24+shake,64,t.z,22,15,18,[130,174,98]); + } + for(const a of s.apples){ + if(a.zp.z+285)continue; + const size=1+Math.min(1,a.age/5)*3,color=a.age<4?[151,193,98]:a.age<7?[237,190,81]:[235,87,80]; + shape(a.x,a.y,a.z,size,size*1.05,size,color,true);shape(a.x+.9,a.y+size+1,a.z,1.6,1,1,[76,134,83],true); + } + if(s.bird){const b=s.bird,flap=Math.sin(s.time*22)*4;shape(b.x,b.y,b.z,4,3.5,6,[109,128,195],true);shape(b.x,b.y+3,b.z-4,3,3,3,[160,176,222],true);shape(b.x,b.y+2,b.z-7,1.5,1,2,[247,197,98],true);for(const side of [-1,1])shape(b.x+side*6,b.y+flap,b.z,5,1,3,[87,107,175],true);} + // Bunny body, long pink-lined ears, feet and a cotton tail, all raycast. + const bounce=p.ground?Math.sin(s.time*9)*.3:0,y=p.y; + shape(p.x,y+10,p.z,6,9,6,[251,237,216],true); + shape(p.x,y+20+bounce,p.z+1,7,7,6,[255,244,224],true); + for(const side of [-1,1]){ + shape(p.x+side*3.6,y+32+bounce,p.z+1,2.3,9,2.2,[252,237,220],true); + shape(p.x+side*3.6,y+32+bounce,p.z-1,1.1,6.3,1,[232,157,169],true); + shape(p.x+side*4,y+2,p.z+2,3.2,2.2,5,[237,215,200],true); + shape(p.x+side*6.4,y+22,p.z+2,1.2,1.6,1.8,[55,58,69],true); + } + shape(p.x,y+7,p.z-6,3.6,3.6,3.6,[255,248,234],true); + shape(p.x,y+18,p.z+7,2,1.4,1.5,[231,149,157],true); + if(!depth||depth.length!==w*h)depth=new Float32Array(w*h); + uniforms.set([w,h,cx,cy,f,1,0,-.4,.65,-.64,right.x,right.y,right.z,up.x,up.y,up.z,forward.x,forward.y,forward.z,2,1]); + const start=Date.now();if(api.raycast)api.raycast(shapes,count,uniforms,depth);rayMS=Date.now()-start; + // The bunny's shadow stays on the landing surface while the body jumps. + const landing=s.turtles.find(t=>Math.hypot(t.x-p.x,t.z-p.z)=h)continue;const half=radius*Math.sqrt(Math.max(0,1-row*row/(ry*ry)));let run=-1; + const left=Math.max(0,Math.floor(shadow.x-half)),right=Math.min(w-1,Math.ceil(shadow.x+half)); + for(let x=left;x<=right+1;x++){const visible=x<=right&&depth[y*w+x]>shadow.z-12;if(visible&&run<0)run=x;if(!visible&&run>=0){box(run,y,x-run,1);run=-1;}} + }} + const next=s.turtles.findIndex(t=>!t.visited),target=next<0?{x:0,z:858}:s.turtles[next]; + const q=project(target.x,22+Math.sin(s.time*3)*2,target.z); + if(q&&q.z<540){ink(255,226,146);line(q.x-5,q.y-5,q.x,q.y);line(q.x,q.y,q.x+5,q.y-5);} + const goal=project(0,32,858);if(goal&&goal.z<390){ink(255,236,182);text('FIA',goal.x-9,goal.y);} + const now=Date.now();frames++;if(now-fpsAt>=500){fps=frames*1000/(now-fpsAt);frames=0;fpsAt=now;} + ink(219,235,203);box(5,5,185,44);ink(40,75,72);text('BUNNY HOP',12,12,2);text(`${s.visited}/12 TURTLES ${s.appleCount} APPLES`,12,34);text(`${fps.toFixed(0)} FPS`,w-48,13); + if(demo)text('AUTO HOP / D-PAD TO PLAY',w-144,29); + ink(38,75,73);box(0,h-22,w,22);ink(244,237,199);text('D-PAD MOVE A HOP / HOLD HIGHER',10,h-14); + if(s.won){ink(254,239,203);box(w/2-118,h/2-35,236,70);ink(67,107,79);text('LOVE U FIA',w/2-60,h/2-25,2);text(`${s.appleCount} APPLES / A TO HOP AGAIN`,w/2-99,h/2+10);} + if(frames===1)api.system.writeFile?.('/tmp/ac-bunny-hop.json',JSON.stringify({fps,rayMS,x:p.x,y:p.y,z:p.z,turtles:s.visited,apples:s.appleCount,bird:!!s.bird,won:s.won,splashes:s.splashes,auto:demo,cameraYaw:camYaw,cameraHeight:camHeight})); +} +export function act({event:e,system}){ + if(e.is('keyboard:down')){if(!keys.has(e.key))pressedKeys.add(e.key);keys.add(e.key);} + if(e.is('keyboard:up'))keys.delete(e.key); + if(e.is('keyboard:down:m')){sound=!sound;if(!sound)stop();} + if(e.is('keyboard:down:escape'))system.jump('prompt'); +} diff --git a/fedac/native/pieces/prompt.mjs b/fedac/native/pieces/prompt.mjs index 0c2f5a48c5..24ae5095dc 100644 --- a/fedac/native/pieces/prompt.mjs +++ b/fedac/native/pieces/prompt.mjs @@ -825,12 +825,12 @@ function paint({ wipe, ink, box, write, screen, paintCount, wifi, system }) { } } - const charW = 6; // 6x10 font - const charH = 10; - const lineH = 12; + const charW = 8; // GNU Unifont's native 8x16 cells + const charH = 16; + const lineH = 18; const x0 = 4; const y0 = 4; - const font = "6x10"; + const font = "unifont"; // Cursor blink cursorFrame++; @@ -887,7 +887,7 @@ function paint({ wipe, ink, box, write, screen, paintCount, wifi, system }) { // History below input (also syntax highlighted) let hy = y0 + lineH + 4 + completionH; - for (let i = 0; i < history.length && hy < H - 20; i++) { + for (let i = 0; i < history.length && hy < H - charH - 6; i++) { // Dim the history entries const entry = history[i]; const lower = entry.toLowerCase(); @@ -907,7 +907,7 @@ function paint({ wipe, ink, box, write, screen, paintCount, wifi, system }) { // Message (bottom) if (message.length > 0) { ink(T.fgDim, T.fgDim - 10, T.fgDim + 20); - write(message, { x: x0, y: H - 14, size: 1, font }); + write(message, { x: x0, y: H - charH - 4, size: 1, font }); } } diff --git a/fedac/native/pieces/starfighter.mjs b/fedac/native/pieces/starfighter.mjs new file mode 100644 index 0000000000..ba53849c1a --- /dev/null +++ b/fedac/native/pieces/starfighter.mjs @@ -0,0 +1,296 @@ +// Starfighter, 26.10.04 +// A cockpit space fighter: steer, fire, boost, shields and homing missiles. +import { savedWifi } from '../lib/saved-wifi.mjs'; +const connectSaved=savedWifi(); +const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); +const add=(a,b)=>({x:a.x+b.x,y:a.y+b.y,z:a.z+b.z}); +const sub=(a,b)=>({x:a.x-b.x,y:a.y-b.y,z:a.z-b.z}); +const mul=(v,s)=>({x:v.x*s,y:v.y*s,z:v.z*s}); +const dot=(a,b)=>a.x*b.x+a.y*b.y+a.z*b.z; +const length=v=>Math.hypot(v.x,v.y,v.z); +const unit=v=>mul(v,1/(length(v)||1)); +export function basis(yaw,pitch){ + const forward={x:Math.sin(yaw)*Math.cos(pitch),y:Math.sin(pitch),z:Math.cos(yaw)*Math.cos(pitch)}; + const right={x:Math.cos(yaw),y:0,z:-Math.sin(yaw)}; + const up={x:-Math.sin(yaw)*Math.sin(pitch),y:Math.cos(pitch),z:-Math.cos(yaw)*Math.sin(pitch)}; + return {forward,right,up}; +} +export function segmentSphere(a,b,c,r){const v=sub(b,a),n=dot(v,v),t=n?clamp(dot(sub(c,a),v)/n,0,1):0;return length(sub(add(a,mul(v,t)),c))<=r;} +export function createFlight(seed=417){ + const s={pos:{x:0,y:0,z:0},yaw:0,pitch:0,turn:0,tilt:0,bank:0,health:100,energy:100,kills:0,score:0,time:0,fire:0,missile:0,roll:0,rollTime:0,lastHit:-10,spawn:1,enemies:[],shots:[],sparks:[],stars:[],rocks:[],id:0,shield:false,boost:false,events:[],seed}; + s.random=()=>{s.seed=(Math.imul(s.seed,1664525)+1013904223)>>>0;return s.seed/4294967296;}; + for(let i=0;i<220;i++)s.stars.push({x:(s.random()-.5)*1600,y:(s.random()-.5)*1600,z:(s.random()-.5)*1600}); + // Separate deterministic rock placement leaves combat's random stream intact. + for(let i=0;i<28;i++){ + const angle=i*2.39996,distance=130+(i%7)*78,radius=14+(i*17%29); + s.rocks.push({pos:{x:Math.cos(angle)*(80+i%5*48),y:Math.sin(angle)*(65+i%4*40),z:distance},radius,color:i%3===0?[162,124,100]:i%3===1?[107,127,153]:[144,112,143]}); + } + return s; +} +function target(s,b){let best=null,alignment=.973;for(const e of s.enemies){const a=dot(unit(sub(e.pos,s.pos)),b.forward);if(a>alignment){alignment=a;best=e;}}return best;} +function explode(s,p,color,count=16){for(let i=0;i200)s.sparks.splice(0,s.sparks.length-200);} +function damage(s,amount){if(s.rollTime>0)return;if(s.shield){s.energy=Math.max(0,s.energy-amount*.7);s.events.push('shield');return;}s.health=Math.max(0,s.health-amount);s.lastHit=s.time;s.events.push('hit');} +export function flightStep(s,input,dt){ + dt=clamp(dt,0,1/30);s.events=[];s.time+=dt; + for(const k of ['fire','missile','roll','rollTime'])s[k]=Math.max(0,s[k]-dt); + if(s.health<=0)return; + s.turn+=(clamp(input.x||0,-1,1)*1.15-s.turn)*(1-Math.exp(-7*dt)); + s.tilt+=(-clamp(input.y||0,-1,1)*.85-s.tilt)*(1-Math.exp(-7*dt)); + s.yaw+=s.turn*dt;s.pitch=clamp(s.pitch+s.tilt*dt,-1.15,1.15);s.bank+=(-s.turn*.3-s.bank)*(1-Math.exp(-5*dt)); + const b=basis(s.yaw,s.pitch); + s.boost=!!input.boost&&s.energy>3;s.shield=!!input.shield&&s.energy>3; + s.energy=clamp(s.energy+(s.boost?-24:0)*dt+(s.shield?-26:0)*dt+(!s.boost&&!s.shield?19:0)*dt,0,100); + if(input.roll&&!s.roll&&s.energy>=20){s.roll=3;s.rollTime=.75;s.energy-=20;s.events.push('roll');} + s.pos=add(s.pos,mul(b.forward,(s.boost?190:75)*dt)); + for(const star of s.stars)for(const axis of ['x','y','z']){if(star[axis]-s.pos[axis]>800)star[axis]-=1600;if(star[axis]-s.pos[axis]<-800)star[axis]+=1600;} + for(const rock of s.rocks){ + const d=sub(rock.pos,s.pos); + if(length(d)>1000||dot(d,b.forward)<-220){ + const angle=(s.time*.37+rock.radius)*2.39996,range=90+rock.radius*4; + rock.pos=add(add(s.pos,mul(b.forward,650+rock.radius*3)),add(mul(b.right,Math.cos(angle)*range),mul(b.up,Math.sin(angle)*range))); + } + } + if(input.fire&&!s.fire){ + const lock=target(s,b),aim=lock?unit(sub(lock.pos,s.pos)):b.forward; + for(const sign of [-1,1]){const pos=add(add(s.pos,mul(b.right,sign*2.5)),mul(b.forward,5));s.shots.push({pos,old:pos,vel:mul(aim,650),life:1.6,enemy:false,missile:false});} + s.fire=.15;s.events.push('fire'); + } + if(input.missile&&!s.missile){const lock=target(s,b);if(lock){const pos=add(s.pos,mul(b.forward,8));s.shots.push({pos,old:pos,vel:mul(b.forward,260),life:5,enemy:false,missile:true,target:lock.id});s.missile=4;s.events.push('missile');}} + s.spawn-=dt; + if(s.spawn<=0&&s.enemies.length<6){ + const distance=260+s.random()*180,offset=add(mul(b.right,(s.random()-.5)*distance*.55),mul(b.up,(s.random()-.5)*distance*.35)); + const pos=add(add(s.pos,mul(b.forward,distance)),offset); + s.enemies.push({id:++s.id,pos,hp:2,fire:2+s.random()*2,phase:s.random()*6.28,age:0});s.spawn=Math.max(1.5,3.5-s.kills*.035); + } + for(const e of s.enemies){ + e.age+=dt;const d=sub(s.pos,e.pos),range=length(d),approach=unit(d); + // A slow weave makes the intercept visible without jerky screen-space motion. + e.pos=add(e.pos,add(mul(approach,(range<85?-20:32)*dt),mul(b.right,Math.sin(e.age*1.4+e.phase)*9*dt))); + e.fire-=dt; + if(e.fire<=0&&range<500){const aim=unit(sub(add(s.pos,mul(b.forward,(s.boost?190:75)*range/330*.45)),e.pos));const pos={...e.pos};s.shots.push({pos,old:pos,vel:mul(aim,235),life:4,enemy:true});e.fire=Math.max(1.1,2.5-s.kills*.025)+s.random();} + if(range<10){damage(s,12);e.hp=0;explode(s,e.pos,'orange');} + if(range>1000)e.hp=0; + } + for(const shot of s.shots){ + shot.old=shot.pos; + if(shot.missile){const e=s.enemies.find(e=>e.id===shot.target&&e.hp>0);if(e)shot.vel=mul(unit(add(mul(unit(shot.vel),.85),mul(unit(sub(e.pos,shot.pos)),.15))),310);} + shot.pos=add(shot.pos,mul(shot.vel,dt));shot.life-=dt; + if(shot.enemy){if(segmentSphere(shot.old,shot.pos,s.pos,5)){damage(s,9);shot.life=0;}} + else for(const e of s.enemies)if(e.hp>0&&segmentSphere(shot.old,shot.pos,e.pos,8)){ + e.hp-=shot.missile?4:1;shot.life=0; + if(e.hp<=0){s.kills++;s.score+=100;explode(s,e.pos,'orange');s.events.push('kill');s.health=Math.min(100,s.health+2);}else{explode(s,e.pos,'cyan',4);s.events.push('impact');}break; + } + } + s.enemies=s.enemies.filter(e=>e.hp>0);s.shots=s.shots.filter(p=>p.life>0).slice(-120); + for(const p of s.sparks){p.pos=add(p.pos,mul(p.vel,dt));p.life-=dt;}s.sparks=s.sparks.filter(p=>p.life>0); +} +export function autopilot(s){ + const b=basis(s.yaw,s.pitch); + const e=[...s.enemies].sort((a,c)=>length(sub(a.pos,s.pos))-length(sub(c.pos,s.pos)))[0]; + if(!e)return {x:Math.sin(s.time*.3)*.12,y:s.pitch*.8,fire:false}; + const d=sub(e.pos,s.pos),range=length(d),yaw=Math.atan2(d.x,d.z),pitch=Math.atan2(d.y,Math.hypot(d.x,d.z)); + const turn=Math.atan2(Math.sin(yaw-s.yaw),Math.cos(yaw-s.yaw)),alignment=dot(unit(d),b.forward); + const danger=s.shots.some(p=>p.enemy&&length(sub(p.pos,s.pos))<65); + return {x:clamp(turn*3,-1,1),y:clamp((s.pitch-pitch)*4,-1,1),fire:alignment>.96,missile:alignment>.985&&range>140,shield:danger,roll:danger&&s.energy>35,boost:range>430&&alignment>.98&&s.energy>50}; +} +let state=createFlight(),pad={},previous=0,keys=new Set(),freshKeys=new Set(),last=0,accum=0,helperAt=0,pending={},fps=0,fpsAt=0,frames=0,sound=true; +let auto=true,manualUntil=0,deathAt=0; +function startReader(system){system.pty2?.spawn('/bin/sh',['-c','exec /mnt/tools/ac-usb-controller'],40,10);helperAt=Date.now();} +export function boot({system}){system.startSSH?.();last=fpsAt=Date.now();try{pad=JSON.parse(system.readFile('/tmp/ac-controller.json')||'{}');}catch{}if(!pad.at||Date.now()-pad.at>1500)startReader(system);} +// Bounded, frame-driven audio: no timer catch-up or lingering music loops. +export function createSoundtrack(){ + const voices=[],cooldown={};let nextBeat=0,step=0,side=1; + const hz=n=>440*2**((n-69)/12); + function stop(){for(const v of voices)v.voice.kill?.(.025);voices.length=0;nextBeat=0;} + function tick(api,events,now,enabled=true){ + if(!enabled){stop();return;} + for(let i=voices.length-1;i>=0;i--){const v=voices[i],t=(now-v.at)/(v.duration*1000);if(t>=1){voices.splice(i,1);continue;}if(v.from!==v.to)v.voice.update?.({tone:v.from*(v.to/v.from)**t});} + function note(from,to,duration,volume,pan=0,attack=.004){ + if(voices.length>=16)return; + const voice=api.sound?.synth?.({type:'sine',tone:from,duration,volume,pan,attack,decay:duration*.85}); + if(voice)voices.push({voice,at:now,from,to,duration}); + } + for(const event of new Set(events)){ + if(now<(cooldown[event]||0))continue;cooldown[event]=now+(event==='fire'?110:180); + if(event==='fire'){side=-side;note(290,65,.16,.055,side*.3);} + else if(event==='kill'||event==='hit'){note(110,28,.65,.13);note(73,34,.48,.08,-.2);note(175,45,.26,.045,.2);} + else if(event==='missile')note(190,38,.42,.085); + else if(event==='impact')note(145,55,.14,.055); + else if(event==='shield')note(196,98,.24,.055); + else if(event==='roll')note(65,130,.35,.05); + } + if(now>=nextBeat){ + // 96 BPM, eighth notes. A stalled frame advances once, never a burst. + nextBeat=now+312.5;const beat=step%16,bar=Math.floor(step/16)%4,root=[36,32,39,34][bar]; + if(beat%4===0){note(hz(root),hz(root),1.05,.065,0,.012);note(78,35,.16,.04);} + const melody=[12,19,15,22,19,15,24,22,19,12,15,19,22,19,15,10]; + note(hz(root+melody[beat]),hz(root+melody[beat]),beat%4===3?.5:.27,.028,beat%2?.28:-.28,.012); + if(beat===0){note(hz(root+7),hz(root+7),2.3,.018,-.35,.25);note(hz(root+12),hz(root+12),2.3,.018,.35,.25);} + step++; + } + } + return {tick,stop,get voices(){return voices.length;}}; +} +const soundtrack=createSoundtrack(); +function play(api,events){soundtrack.tick(api,events,Date.now(),sound);} +export function leave(){soundtrack.stop();} +export function sim(api){ + connectSaved(api);const now=Date.now(),dt=clamp((now-last)/1000,0,.1);last=now;accum=Math.min(.1,accum+dt); + try{pad=JSON.parse(api.system.readFile('/tmp/ac-controller.json')||'{}');}catch{pad={};} + if(!pad.at||now-pad.at>1500){pad={};if(now-helperAt>5000)startReader(api.system);} + const b=pad.connected&&pad.ready?pad.buttons:0,pressed=b&~previous;previous=b; + if(pressed&4||freshKeys.has('p'))auto=!auto; + if(pressed&0x80||freshKeys.has('r')){state=createFlight();accum=0;pending={};} + let x=(b&0x800||keys.has('arrowright')?1:0)-(b&0x400||keys.has('arrowleft')?1:0),y=(b&0x200||keys.has('arrowdown')?1:0)-(b&0x100||keys.has('arrowup')?1:0); + if(!x&&pad.ready&&Math.abs(pad.lx)>6500)x=clamp(pad.lx/32767,-1,1);if(!y&&pad.ready&&Math.abs(pad.ly)>6500)y=clamp(pad.ly/32767,-1,1); + pending.roll ||= !!(pressed&0x1000)||freshKeys.has('l');pending.missile ||= !!(pressed&0x40)||freshKeys.has('x');freshKeys.clear(); + const input={x,y,fire:!!(b&0x10)||pad.rt>100||keys.has('space'),boost:!!(b&0x2000)||keys.has('shift'),shield:!!(b&0x20)||keys.has('b')}; + if(x||y||input.fire||input.boost||input.shield||pending.roll||pending.missile)manualUntil=now+4000; + if(state.health>0)deathAt=0;else if(!deathAt)deathAt=now; + if(auto&&deathAt&&now-deathAt>3000){state=createFlight();deathAt=0;} + let events=[];while(accum>=1/120){flightStep(state,auto&&now>=manualUntil?autopilot(state):{...input,...pending},1/120);events.push(...state.events);pending={};accum-=1/120;}play(api,events); +} +// Analytic ray/ellipsoid intersection. Rays have view-space z=1, so the +// returned parameter is directly comparable to projected object depth. +export function rayEllipsoid(dx,dy,x,y,z,rx,ry,rz){ + const ix=1/(rx*rx),iy=1/(ry*ry),iz=1/(rz*rz); + const a=dx*dx*ix+dy*dy*iy+iz,b=-(x*dx*ix+y*dy*iy+z*iz),c=x*x*ix+y*y*iy+z*z*iz-1; + const discriminant=b*b-a*c;if(discriminant<0)return Infinity; + const root=Math.sqrt(discriminant),near=(-b-root)/a,far=(-b+root)/a; + return near>1?near:far>1?far:Infinity; +} +let rayCell=8,rayDepth=null,rayCols=0,rayRows=0,rayLastMS=0,raySmoothMS=0,rayCooldown=0,renderFrame=0,shipDepth=null,shipCols=0,shipRows=0; +let nativeShapes=new Float64Array(128*16),nativeUniforms=new Float64Array(21),nativeDepth=null; +function castScene(api,s,b,camera,third,cos,sin,cx,cy,f){ + const {screen,ink,box}=api,w=screen.width,h=third?screen.height:Math.ceil(screen.height*.78),shapes=[]; + let detail=false; + const shape=(pos,rx,ry,rz,color,rock=false,glow=false)=>{ + const d=sub(pos,camera),x=dot(d,b.right),y=dot(d,b.up),z=dot(d,b.forward),r=Math.max(rx,ry,rz); + if(z+r<2)return; + const sx=cx+(x*cos+y*sin)*f/Math.max(2,z),sy=cy+(x*sin-y*cos)*f/Math.max(2,z); + // Bound each ellipsoid axis separately. A thin wing must not cast rays + // across the large square enclosing a sphere with the same wingspan. + const spanR=z>rz?f*(rx+Math.abs(x)*rz/z)/(z-rz):Math.max(w,h)*2; + const spanU=z>rz?f*(ry+Math.abs(y)*rz/z)/(z-rz):Math.max(w,h)*2; + const spanX=Math.abs(cos)*spanR+Math.abs(sin)*spanU,spanY=Math.abs(sin)*spanR+Math.abs(cos)*spanU; + if(sx+spanX<0||sx-spanX>w||sy+spanY<0||sy-spanY>h)return; + shapes.push({x,y,z,rx,ry,rz,color,rock,glow,detail,left:Math.max(0,sx-spanX),right:Math.min(w,sx+spanX),top:Math.max(0,sy-spanY),bottom:Math.min(h,sy+spanY)}); + }; + // Actual sphere surfaces, rather than painted discs. The planet remains far + // away; rocks wrap through a world-space field as the ship flies past. + shape(add(s.pos,mul({x:.44,y:.19,z:.88},2500)),230,230,230,[77,114,167]); + for(const rock of s.rocks)shape(rock.pos,rock.radius,rock.radius,rock.radius,rock.color,true); + detail=true; + const part=(pos,x,y,z)=>add(pos,add(mul(b.right,x),add(mul(b.up,y),mul(b.forward,z)))); + for(const e of s.enemies){ + shape(e.pos,4,3,8,[217,102,71]); + for(const side of [-1,1]){shape(part(e.pos,side*8,0,-1),5,1.3,5,[150,68,77]);shape(part(e.pos,side*10,1,-2),1.1,2.5,5,[247,169,110]);} + shape(part(e.pos,0,2,3),2,1.5,2,[246,220,137],false,true); + } + if(third){ + shape(s.pos,3,2,12,[96,166,182]);shape(part(s.pos,0,-.6,-4),12,.9,5,[89,174,186]); + shape(part(s.pos,0,2,3),1.8,1.4,4,[255,202,114]); + for(const sign of [-1,1]){shape(part(s.pos,sign*5,-.3,-5),1.6,1.4,5,[60,114,153]);shape(part(s.pos,sign*5,-.3,-10),1,1,s.boost?5:2,[124,217,250],false,true);} + } + if(api.raycast){ + const start=Date.now(),light=unit({x:-.5,y:.7,z:-.4}); + shapes.sort((a,b)=>Number(a.detail)-Number(b.detail)||a.z-b.z); + if(!nativeDepth||nativeDepth.length!==w*h)nativeDepth=new Float32Array(w*h); + for(let i=0;ix<0||y<0||x>=w||y>=h?Infinity:nativeDepth[Math.floor(y)*w+Math.floor(x)]}; + } + let estimate=0;for(const o of shapes)if(!o.detail)estimate+=(o.right-o.left)*(o.bottom-o.top)/(rayCell*rayCell); + const cell=Math.max(rayCell,estimate>14000?12:8); + const cols=Math.ceil(w/cell),rows=Math.ceil(h/cell); + if(!rayDepth||cols!==rayCols||rows!==rayRows){rayCols=cols;rayRows=rows;rayDepth=new Float32Array(cols*rows);} + rayDepth.fill(Infinity); + const fineCols=Math.ceil(w/2),fineRows=Math.ceil(h/2); + if(!shipDepth||shipCols!==fineCols||shipRows!==fineRows){shipCols=fineCols;shipRows=fineRows;shipDepth=new Float32Array(fineCols*fineRows);} + shipDepth.fill(Infinity); + const light=unit({x:-.5,y:.7,z:-.4}),lx=dot(light,b.right),ly=dot(light,b.up),lz=dot(light,b.forward); + const start=Date.now(); + for(const o of shapes.sort((a,b)=>Number(a.detail)-Number(b.detail)||a.z-b.z)){ + // Keep ships legible with fine rays after the coarse environment pass. + const step=o.detail?2:cell,outCols=o.detail?fineCols:cols,outRows=o.detail?fineRows:rows,depth=o.detail?shipDepth:rayDepth; + const ix=1/(o.rx*o.rx),iy=1/(o.ry*o.ry),iz=1/(o.rz*o.rz),c=o.x*o.x*ix+o.y*o.y*iy+o.z*o.z*iz-1; + const left=Math.floor(o.left/step),right=Math.min(outCols,Math.ceil(o.right/step)),top=Math.floor(o.top/step),bottom=Math.min(outRows,Math.ceil(o.bottom/step)); + for(let row=top;row=depth[index]||t>=environment)continue;depth[index]=t; + let nx=(t*dx-o.x)*ix,ny=(t*dy-o.y)*iy,nz=(t-o.z)*iz; + const norm=1/Math.sqrt(nx*nx+ny*ny+nz*nz);nx*=norm;ny*=norm;nz*=norm; + let shade=o.glow?1:.18+.82*Math.max(0,nx*lx+ny*ly+nz*lz); + if(o.rock){ + const wx=nx*b.right.x+ny*b.up.x+nz*b.forward.x,wy=nx*b.right.y+ny*b.up.y+nz*b.forward.y,wz=nx*b.right.z+ny*b.up.z+nz*b.forward.z; + const grain=((Math.floor(wx*13)*73856093)^(Math.floor(wy*13)*19349663)^(Math.floor(wz*13)*83492791))>>>0; + shade*=grain%11<2?.48:.78+(grain%5)*.07; + } + // Quantized illumination keeps the ray layer deliberately pixelated. + shade=Math.round(shade*32)/32;const fog=clamp(1-t/1700,.3,1); + ink(Math.round(o.color[0]*shade*fog+6*(1-fog)),Math.round(o.color[1]*shade*fog+13*(1-fog)),Math.round(o.color[2]*shade*fog+28*(1-fog))); + box(col*step,row*step,Math.min(step,w-col*step),Math.min(step,h-row*step)); + } + } + } + rayLastMS=Date.now()-start;raySmoothMS=raySmoothMS*.85+rayLastMS*.15; + if(++rayCooldown>=45){if(raySmoothMS>7&&rayCell<12)rayCell+=2;else if(raySmoothMS<4&&rayCell>8)rayCell-=2;rayCooldown=0;} + return {cell,cols,rows,depthAt:(x,y)=>x<0||y<0||x>=w||y>=h?Infinity:Math.min(rayDepth[Math.floor(y/cell)*cols+Math.floor(x/cell)],shipDepth[Math.floor(y/2)*fineCols+Math.floor(x/2)])}; +} + +export function paint(api){ + const {wipe,ink,box,line,circle,write,screen}=api,w=screen.width,h=screen.height,s=state,b=basis(s.yaw,s.pitch),cx=w*.5,cy=h*.43,f=w*.68; + const third=true,distance=120; + const camera=third?add(sub(s.pos,mul(b.forward,distance)),mul(b.up,distance*.18)):s.pos; + const roll=s.bank+(s.rollTime>0?(1-s.rollTime/.75)*Math.PI*2:0),cos=Math.cos(roll),sin=Math.sin(roll); + const project=p=>{const d=sub(p,camera),z=dot(d,b.forward);if(z<2)return null;let x=dot(d,b.right)*f/z,y=-dot(d,b.up)*f/z;return {x:cx+x*cos-y*sin,y:cy+x*sin+y*cos,z,scale:f/z};}; + const visible=p=>p&&p.x>-30&&p.y>-30&&p.xwrite(t,{x:Math.round(x),y:Math.round(y),size,font:'font_1'}); + wipe(4,7,19); + // Tiny distant stars and longer nearby trails describe the ship's speed. + for(let i=0;ivisible(p)&&p.zlength(sub(b.pos,s.pos))-length(sub(a.pos,s.pos)))){ + const p=project(e.pos);if(!unobscured(p,12))continue;const k=p.scale; + if(lock===e){ink(112,248,216);const r=clamp(15*k,10,60);for(const sign of [-1,1]){line(p.x+sign*r,p.y-r,p.x+sign*(r-5),p.y-r);line(p.x+sign*r,p.y+r,p.x+sign*(r-5),p.y+r);line(p.x+sign*r,p.y-r,p.x+sign*r,p.y-r+5);line(p.x+sign*r,p.y+r,p.x+sign*r,p.y+r-5);}text(`${Math.round(p.z)}m`,p.x+r+4,p.y-3);} + } + for(const shot of s.shots){const p=project(shot.pos),tail=project(sub(shot.pos,mul(unit(shot.vel),shot.missile?18:12)));if(!unobscured(p)||!tail)continue;ink(...(shot.enemy?[255,82,106]:shot.missile?[255,221,134]:[114,250,230]));line(tail.x,tail.y,p.x,p.y);if(shot.enemy)box(p.x-1,p.y-1,3,3);} + for(const spark of s.sparks){const p=project(spark.pos);if(!unobscured(p))continue;ink(...(spark.color==='orange'?[255,145+Math.round(spark.life*70),90]:[120,230,255]));box(p.x,p.y,Math.min(4,Math.max(1,p.scale*2)),2);} + const aim=third?project(add(s.pos,mul(b.forward,240))):{x:cx,y:cy}; + ink(...(lock?[118,250,210]:[95,152,174]));line(aim.x-11,aim.y,aim.x-5,aim.y);line(aim.x+5,aim.y,aim.x+11,aim.y);line(aim.x,aim.y-10,aim.x,aim.y-5);line(aim.x,aim.y+5,aim.x,aim.y+10);box(aim.x,aim.y,1,1); + // Canopy rails: their heavy outer lines frame a clear central windshield. + if(!third)for(const sign of [-1,1]){const x=sign<0?0:w;for(let i=-5;i<=5;i++){ink(17,30,45);line(x,h*.8+i,cx+sign*w*.40,h*.11+i);line(cx+sign*w*.40,h*.11+i,cx+sign*w*.20,i);}ink(65,87,103);line(x,h*.8-6,cx+sign*w*.40,h*.11-6);line(cx+sign*w*.40,h*.11-6,cx+sign*w*.20,-6);} + const deck=Math.round(h*.78); + if(!third){ + ink(13,24,35);box(0,deck,w,h-deck);ink(61,80,94);line(0,deck,w,deck);ink(29,44,57);box(0,deck+3,w,5); + // Shield and energy instruments flank a small live radar. + ink(3,12,21);box(12,deck+15,150,39);box(w-162,deck+15,150,39);box(cx-55,deck+10,110,51); + ink(119,187,197);text('HULL',20,deck+21);text(s.shield?'SHIELD ACTIVE':'ENERGY',w-154,deck+21); + for(let i=0;i<20;i++){ink(...(i=500){fps=frames*1000/(now-fpsAt);frames=0;fpsAt=now;} + if(++renderFrame%60===0)api.system.writeFile?.('/tmp/ac-starfighter-render.json',JSON.stringify({fps,rayMS:rayLastMS,averageRayMS:raySmoothMS,cell:rays.cell,view:third?'chase':'cockpit',time:s.time,audioVoices:soundtrack.voices,sound})); + ink(168,199,211);text(`FIA-01 ${auto&&Date.now()>=manualUntil?'AUTO':s.boost?'BOOST':'MANUAL'} / ${third?'CHASE':'COCKPIT'}`,14,14);text(`${s.score} PTS ${fps.toFixed(0)} FPS`,w-132,14);text(`RAY ${Math.ceil(w/rays.cell)}x${Math.ceil((third?h:h*.78)/rays.cell)}`,14,28); + ink(139,226,207);text(s.missile>0?`MISSILE ${s.missile.toFixed(1)}s`:lock?'X: MISSILE LOCK':'MISSILE READY',cx-51,third?h-29:deck-15); + ink(179,197,205);text('A/RT FIRE B SHIELD X MISSILE RB BOOST LB ROLL MENU AUTO',Math.max(8,cx-168),h-12); + if(s.shield){ink(78,176,193);line(12,40,12,deck-15);line(w-13,40,w-13,deck-15);} + if(s.time-s.lastHit<.25){ink(240,76,105);for(let i=0;i<3;i++){line(i,i,w-1-i,i);line(i,i,i,deck);line(w-1-i,i,w-1-i,deck);}} + if(!pad.ready||!pad.connected||now-pad.at>1500){ink(238,190,132);text('ARROWS STEER / SPACE FIRES / USB CONTROLLER OFFLINE',20,34);} + if(s.health<=0){ink(5,10,23);box(cx-136,cy-38,272,76);ink(255,140,147);text('SHIP LOST',cx-54,cy-25,2);ink(196,221,226);text(`${s.kills} DRONES DOWN / Y TO RETRY`,cx-87,cy+10);} +} +export function act({event:e,system}){if(e.is('keyboard:down')){if(!keys.has(e.key))freshKeys.add(e.key);keys.add(e.key);}if(e.is('keyboard:up'))keys.delete(e.key);if(e.is('keyboard:down:m')){sound=!sound;if(!sound)soundtrack.stop();}if(e.is('keyboard:down:escape'))system.jump('prompt');} diff --git a/fedac/native/src/js-bindings.c b/fedac/native/src/js-bindings.c index 2879d58a64..e8761b6b64 100644 --- a/fedac/native/src/js-bindings.c +++ b/fedac/native/src/js-bindings.c @@ -51,6 +51,7 @@ static JSValue js_usb_midi_all_notes_off(JSContext *ctx, JSValueConst this_val, static JSClassID form_class_id = 0; static JSClassID painting_class_id = 0; +#include "raycast-js.h" #include "screen-triangle.h" #include "screen-gpu.h" #include "comic-font.h" @@ -8516,6 +8517,7 @@ static JSValue build_api(JSContext *ctx, ACRuntime *rt, const char *phase) { // painting(w, h, callback) — creates a stub painting object with width/height JS_SetPropertyStr(ctx, api, "painting", JS_NewCFunction(ctx, js_painting, "painting", 3)); + JS_SetPropertyStr(ctx, api, "raycast", JS_NewCFunction(ctx, js_raycast, "raycast", 4)); // paste, page (real implementations), layer, sharpen (stubs) JS_SetPropertyStr(ctx, api, "paste", JS_NewCFunction(ctx, js_paste, "paste", 7)); diff --git a/fedac/native/src/raycast-js.h b/fedac/native/src/raycast-js.h new file mode 100644 index 0000000000..da980e25ae --- /dev/null +++ b/fedac/native/src/raycast-js.h @@ -0,0 +1,46 @@ +#ifndef AC_RAYCAST_JS_H +#define AC_RAYCAST_JS_H +#include "raycast.h" +// Retain each backing ArrayBuffer throughout the synchronous render. The JS +// caller owns/reuses buffers; no pointers survive this call or context teardown. +static void *ac_ray_array(JSContext *ctx,JSValueConst value,size_t element, + size_t needed,JSValue *owner){ + size_t offset,length,bytes; + *owner=JS_GetTypedArrayBuffer(ctx,value,&offset,&length,&bytes); + if(JS_IsException(*owner))return NULL; + size_t total=0;uint8_t *p=JS_GetArrayBuffer(ctx,&total,*owner); + if(!p||bytes!=element||offset%element||lengthtotal||length>total-offset){ + JS_ThrowRangeError(ctx,"raycast buffer type, alignment or length");return NULL; + } + return p+offset; +} +static JSValue js_raycast(JSContext *ctx,JSValueConst self,int argc,JSValueConst *argv){ + (void)self; + if(argc<4||!current_rt||!current_rt->graph||!current_rt->graph->fb)return JS_UNDEFINED; + int count=0;if(JS_ToInt32(ctx,&count,argv[1])<0)return JS_EXCEPTION; + if(count<0||count>128)return JS_ThrowRangeError(ctx,"raycast accepts at most 128 ellipsoids"); + JSValue owners[3]={JS_UNDEFINED,JS_UNDEFINED,JS_UNDEFINED}; + JSValue result=JS_EXCEPTION; + double *u=ac_ray_array(ctx,argv[2],8,21*sizeof(double),&owners[0]);if(!u)goto done; + for(int i=0;i<21;i++)if(!isfinite(u[i])){JS_ThrowRangeError(ctx,"nonfinite raycast uniform");goto done;} + ACFramebuffer *fb=current_rt->graph->fb; + if(u[0]<1||u[1]<1||u[0]>fb->width||u[1]>fb->height||u[0]>4096||u[1]>4096||floor(u[0])!=u[0]||floor(u[1])!=u[1]||u[4]<1||u[4]>100000||fabs(u[2])>100000||fabs(u[3])>100000){JS_ThrowRangeError(ctx,"invalid raycast viewport");goto done;} + for(int i=5;i<=18;i++)if(fabs(u[i])>1.001){JS_ThrowRangeError(ctx,"invalid raycast basis");goto done;} + for(int i=19;i<=20;i++)if(u[i]<1||u[i]>16||floor(u[i])!=u[i]){JS_ThrowRangeError(ctx,"invalid raycast cell size");goto done;} + double *shapes=ac_ray_array(ctx,argv[0],8,(size_t)count*16*sizeof(double),&owners[1]);if(!shapes)goto done; + for(int n=0;n1e7){JS_ThrowRangeError(ctx,"invalid raycast ellipsoid");goto done;} + for(int i=3;i<6;i++)if(o[i]<.001){JS_ThrowRangeError(ctx,"raycast radii must be positive");goto done;} + } + float *depth=ac_ray_array(ctx,argv[3],4,(size_t)u[0]*(size_t)u[1]*sizeof(float),&owners[2]);if(!depth)goto done; + // Overlapping backing buffers would corrupt uniforms/shapes while writing Z. + // Compare checked byte ranges via integer addresses (unrelated C pointers + // cannot be ordered portably). + uintptr_t d=(uintptr_t)depth,de=d+(size_t)u[0]*(size_t)u[1]*sizeof(float); + uintptr_t a=(uintptr_t)shapes,ae=a+(size_t)count*16*sizeof(double),v=(uintptr_t)u,ve=v+21*sizeof(double); + if((dpixels,fb->stride,depth,shapes,count,u);result=JS_TRUE; + done:for(int i=0;i<3;i++)JS_FreeValue(ctx,owners[i]);return result; +} +#endif diff --git a/fedac/native/src/raycast.h b/fedac/native/src/raycast.h new file mode 100644 index 0000000000..364837feca --- /dev/null +++ b/fedac/native/src/raycast.h @@ -0,0 +1,56 @@ +#ifndef AC_RAYCAST_H +#define AC_RAYCAST_H +#include +#include +#include +// Packed ellipsoids: center xyz, radii xyz, RGB, rock, glow, detail, bounds LRTB. +// Uniforms: width,height,cx,cy,f,cos,sin, light xyz, right xyz, up xyz, +// forward xyz, coarse cell, detail cell. Depth is full-resolution view-space Z. +static inline double ac_ray_clamp(double x,double lo,double hi){return fmax(lo,fmin(hi,x));} +static inline void ac_raycast(uint32_t *pixels,int stride,float *depth, + const double *shapes,int count,const double *u){ + const int w=(int)u[0],h=(int)u[1]; + for(size_t i=0;i<(size_t)w*h;i++)depth[i]=INFINITY; + for(int n=0;n{for(let i=0;i{const s=createMeadow();for(let i=0;i<120*100&&!s.won;i++)meadowStep(s,bunnyPilot(s));assert.equal(s.visited,12);assert.ok(s.won);assert.equal(s.splashes,0);assert.ok(s.appleCount>0);}); +test('holding hop gives more height than a short tap',()=>{const high=createMeadow(),low=createMeadow();meadowStep(high,{jumpPressed:true,jumpHeld:true});meadowStep(low,{jumpPressed:true});advance(high,.3,{jumpHeld:true});advance(low,.3);assert.ok(high.p.y>low.p.y+2);}); +test('missing a turtle respawns at the last turtle reached',()=>{const s=createMeadow();s.checkpoint=3;s.p.x=300;s.p.y=-40;meadowStep(s,{});assert.equal(s.p.x,s.turtles[3].x);assert.equal(s.p.z,s.turtles[3].z);assert.equal(s.splashes,1);}); +test('apples grow one at a time, ripen, and fall on turtle backs',()=>{const s=createMeadow();s.nextBird=Infinity;advance(s,8);assert.equal(s.apples.filter(a=>a.age<7).length,1);assert.ok(s.apples.some(a=>a.age>=7&&!a.falling));advance(s,6);assert.ok(s.apples.some(a=>a.falling&&a.ground));}); +test('a visiting bird shakes a ripe apple loose before natural falling',()=>{const s=createMeadow();advance(s,11.8);assert.ok(s.bird?.shaken);assert.ok(s.apples.some(a=>a.falling&&a.age<12));}); +test('ripe apples can be collected once',()=>{const s=createMeadow();s.nextApple=Infinity;s.apples=[{id:0,tree:0,x:0,z:0,y:15,age:8,vy:0,falling:true,ground:true}];meadowStep(s,{});assert.equal(s.appleCount,1);assert.equal(s.apples.length,0);meadowStep(s,{});assert.equal(s.appleCount,1);}); diff --git a/fedac/native/tests/raycast.c b/fedac/native/tests/raycast.c new file mode 100644 index 0000000000..49fc562da1 --- /dev/null +++ b/fedac/native/tests/raycast.c @@ -0,0 +1,22 @@ +#include "../src/raycast.h" +#include +#include +int main(void){ + enum{W=41,H=31,STRIDE=45};uint32_t pixels[STRIDE*H];float depth[W*H]; + for(int i=0;i0);assert((pixels[15*STRIDE+20]&0x0000ff00)==0); + assert(isinf(depth[0]));assert(pixels[0]==0x12345678); + for(int y=0;y{for(let y=-3;y<3;y+=.3)for(let p=-1;p<1;p+=.2){const b=basis(y,p),dot=(a,b)=>a.x*b.x+a.y*b.y+a.z*b.z;for(const v of Object.values(b))assert.ok(Math.abs(dot(v,v)-1)<1e-12);assert.ok(Math.abs(dot(b.forward,b.up))<1e-12);assert.ok(Math.abs(dot(b.forward,b.right))<1e-12);}}); +test('fast projectile swept hit catches targets between frames',()=>{assert.ok(segmentSphere({x:0,y:0,z:0},{x:0,y:0,z:40},{x:0,y:0,z:20},3));assert.ok(!segmentSphere({x:0,y:0,z:0},{x:0,y:0,z:40},{x:8,y:0,z:20},3));}); +test('firing destroys a centered drone and awards points',()=>{const s=createFlight();s.spawn=99;s.enemies.push({id:1,pos:{x:0,y:0,z:100},hp:2,fire:99,phase:0,age:0});for(let i=0;i<100;i++)flightStep(s,{fire:true},1/120);assert.equal(s.kills,1);assert.equal(s.score,100);}); +test('shield absorbs a hit and energy recovers after release',()=>{const s=createFlight();s.shots.push({pos:{x:0,y:0,z:1},old:{x:0,y:0,z:1},vel:{x:0,y:0,z:-100},life:1,enemy:true});flightStep(s,{shield:true},1/120);assert.equal(s.health,100);assert.ok(s.energy<95);const old=s.energy;for(let i=0;i<20;i++)flightStep(s,{},1/120);assert.ok(s.energy>old);}); +test('long flight stays finite and bounded; depleted energy disables boost',()=>{const s=createFlight();for(let i=0;i<12000;i++){s.health=100;flightStep(s,{x:Math.sin(i/300),y:Math.sin(i/230),fire:true,boost:true,shield:true,missile:i%300===0,roll:i%450===0},1/120);assert.ok(s.enemies.length<=6&&s.shots.length<=120&&s.sparks.length<=200);assert.ok(s.energy>=0&&s.energy<=100);assert.ok([s.pos.x,s.pos.y,s.pos.z,s.yaw,s.pitch].every(Number.isFinite));}s.energy=0;flightStep(s,{boost:true},1/120);assert.equal(s.boost,false);}); + +test("autopilot acquires and destroys drones without input",()=>{const s=createFlight();for(let i=0;i<120*60;i++)flightStep(s,autopilot(s),1/120);assert.ok(s.kills>=10);assert.ok(s.health>0);}); + +test('raycast finds front surfaces, misses, and exits from inside',()=>{assert.equal(rayEllipsoid(0,0,0,0,10,2,2,2),8);assert.equal(rayEllipsoid(0,0,0,0,10,2,2,4),6);assert.equal(rayEllipsoid(0,0,10,0,10,2,2,2),Infinity);assert.equal(rayEllipsoid(0,0,0,0,0,3,3,3),3);assert.equal(rayEllipsoid(0,0,0,0,-10,2,2,2),Infinity);}); + +test('soundtrack bounds voices, avoids stalled-frame bursts, and mutes immediately',()=>{ + const score=createSoundtrack(),notes=[];let killed=0; + const api={sound:{synth(options){notes.push(options);return {update(){},kill(){killed++;}};}}}; + for(let t=0;t<10000;t+=16){score.tick(api,['fire','kill','impact','missile'],t);assert.ok(score.voices<=16);} + assert.ok(notes.every(n=>n.type==='sine'&&n.tone<400&&n.duration>0)); + const before=notes.length;score.tick(api,[],100000);assert.ok(notes.length-before<=5); + const active=score.voices;score.tick(api,[],100001,false);assert.equal(score.voices,0);assert.equal(killed,active); + const muted=notes.length;score.tick(api,['fire','kill'],101000,false);assert.equal(notes.length,muted); + score.tick(api,[],102000,true);assert.ok(score.voices>0);score.stop();assert.equal(score.voices,0); +}); diff --git a/fuser-gun/.gitignore b/fuser-gun/.gitignore new file mode 100644 index 0000000000..9a142b7802 --- /dev/null +++ b/fuser-gun/.gitignore @@ -0,0 +1,2 @@ +# Scout output: downloaded Instagram media + per-handle JSON. Fixtures are tracked; out/ is not. +out/ diff --git a/fuser-gun/README.md b/fuser-gun/README.md new file mode 100644 index 0000000000..3ae6b14744 --- /dev/null +++ b/fuser-gun/README.md @@ -0,0 +1,83 @@ +# fuser-gun + +Scout public Instagram posts for a Fuser ICP brief. Exa public search is the default; +Business Discovery is optional. No Fuser credits, board builds, outreach or Instagram writes. +Node builtins only. + +```sh +node fuser-gun/ig-gun.mjs scout areaware +node fuser-gun/ig-gun.mjs brief areaware --product "Cache Box" --rank 001 +node fuser-gun/ig-gun.mjs rank areaware +``` + +`scout` saves indexed evidence, attributable post links, available thumbnails and rankings +under `out//`. It rejects other accounts merely mentioning the target. Search is +incomplete and can be stale: follower counts, engagement, post dates and exact media types +remain unknown. Thumbnail links can expire. A visual check must confirm the account, +product, complete crop and absence of people before generation. + +`brief` writes `brief.md`, `script.txt`, `email.md`, `board-spec.json`, `icp-queue.line`, +`provenance.json` and dossier-shaped `data/`. These are proposals, not completed work. +Missing reference images and unverified product guesses are recorded. A fresh successful +scout removes drafts derived from the previous scout; regenerate them with `brief`. + +## Sources + +Exa uses its [keyless, rate-limited MCP endpoint](https://exa.ai/docs/get-started/exa-mcp). +No token or account setup is needed for this route. Search errors never substitute fixtures. + +```sh +node fuser-gun/ig-gun.mjs scout areaware --limit 12 --top 4 +node fuser-gun/ig-gun.mjs scout areaware --query 'Areaware Instagram Cache Box product' +node fuser-gun/ig-gun.mjs scout areaware --no-media +node fuser-gun/ig-gun.mjs scout areaware --search-results results.json +``` + +Saved search input uses `{ "provider": "exa", "query": "...", "retrieved_at": "...", +"results": [{ "url": "...", "title": "...", "text": "...", "image": "..." }] }`. +The source may be another public search tool; preserve its name and retrieved evidence. +A target handle in a post URL, indexed author or title establishes a search candidate. +Search metadata is not independent confirmation of authorship. + +`--source graph` uses Facebook Login Business Discovery. It reads +`_FB_IG_USER_ID` and `_FB_TOKEN` from the environment or +`vault//instagram.env`; aliases are aesthetic, whistlegraph, oskiewar and menuband. +`--as ` chooses the reader. Existing Instagram Login tokens do not supply this edge. +An explicit `--host instagram` remains available for diagnostics. Credentials are never +printed or saved in output. Graph setup is unnecessary for Exa scouting. + +`--fixture` explicitly selects the offline Areaware or Olivia Rodrigo demo. Fixture captions +are authored examples, not real post captions; permalinks and counts are absent. +The Olivia fixture ranks merchandise above portrait/concert examples. + +## Evidence and handoff + +- `search.json` and `search-excluded.json`: retrieved results and rejection reasons. +- `profile.json`, `media.json`, `ranking.json`, `api.json`: normalized data, ranking and source. +- `media-index.json`, `media.csv`: download status, source links, dimensions and hashes. +- `provenance.json`: the brief's reference and remaining visual check. + +The board spec follows the existing ICP lane: credited reference, sibling image, blank +carrier, views, Meshy model, staged shot and Kling push-in. The generated budget is an +estimate; the worker reads live prices before generation. Keep lettering separate from the +object and do not use people or minors as generation inputs. Public images remain their +owners' work; retain attribution. `out/` is ignored by git. + +Model selection starts with the default **most-used** order at +[Fuser Models](https://fuser.studio/models), as Hirad confirmed through Jeffrey on +October 5, 2026. The worker refreshes the catalog before building, then selects compatible +models by reference fidelity, output needs and live price. Record the observed family +order, check date, exact variant and reason in the spec. Family popularity does not rank +its variants; the new-models section is separate. Image specs explicitly select Nano +Banana Pro instead of inheriting the node's default model. + +Brief flags: `--hero `, `--product "Name"`, `--voice stock|jeffrey`, `--rank `. +`IG_GUN_OUT` selects a separate output directory for isolated checks. + +```sh +node --test fuser-gun/public-search.test.mjs +node fuser-gun/ig-gun.mjs scout areaware --fixture --no-media +``` + +The public-search tests check attribution, unknown metadata, source provenance, rate-limit +failure and refusal to replace live results with fixtures. diff --git a/fuser-gun/fixtures/areaware.json b/fuser-gun/fixtures/areaware.json new file mode 100644 index 0000000000..99b399078c --- /dev/null +++ b/fuser-gun/fixtures/areaware.json @@ -0,0 +1,67 @@ +{ + "_note": "HAND-WRITTEN DEMO FIXTURE, used only with --fixture. Products from the brand's public site (areaware.com). Captions are our descriptions, NOT real post captions. Counts null, permalinks null: a human confirms the real post before any board is built.", + "profile": { + "username": "areaware", + "name": "Areaware", + "kind": "brand", + "address_as": "Areaware", + "biography": null, + "website": "https://www.areaware.com", + "followers_count": null, + "media_count": null, + "contact": "(wholesale/press contact on areaware.com; never a DM)" + }, + "media": [ + { + "id": "fixture-gradient-puzzle", + "media_type": "IMAGE", + "fixture_kind": "product", + "product": "Gradient Puzzle", + "caption": "Fixture: the Gradient Puzzle by Bryce Wilner, top-down, pieces mid-assembly beside its box. Product-only shot.", + "permalink": null, + "media_url": null, + "like_count": null, + "comments_count": null, + "timestamp": "2026-05-01T00:00:00+0000", + "aspect": "1:1" + }, + { + "id": "fixture-cubebot", + "media_type": "IMAGE", + "fixture_kind": "product", + "product": "Cubebot", + "caption": "Fixture: Cubebot by David Weeks, the wooden robot toy folded into its cube, on a plain surface.", + "permalink": null, + "media_url": null, + "like_count": null, + "comments_count": null, + "timestamp": "2026-02-01T00:00:00+0000", + "aspect": "4:5" + }, + { + "id": "fixture-blockitecture", + "media_type": "CAROUSEL_ALBUM", + "fixture_kind": "product", + "product": "Blockitecture", + "caption": "Fixture: Blockitecture by James Paulius, a stacked set of the wooden blocks with its box.", + "permalink": null, + "media_url": null, + "like_count": null, + "comments_count": null, + "timestamp": "2025-12-01T00:00:00+0000", + "aspect": "1:1" + }, + { + "id": "fixture-team", + "media_type": "IMAGE", + "fixture_kind": "person", + "caption": "Fixture: the team at a trade fair booth. People, never a source; here so the ranker demotes it.", + "permalink": null, + "media_url": null, + "like_count": null, + "comments_count": null, + "timestamp": "2026-04-01T00:00:00+0000", + "aspect": "4:5" + } + ] +} diff --git a/fuser-gun/fixtures/oliviarodrigo.json b/fuser-gun/fixtures/oliviarodrigo.json new file mode 100644 index 0000000000..05de961755 --- /dev/null +++ b/fuser-gun/fixtures/oliviarodrigo.json @@ -0,0 +1,92 @@ +{ + "_note": "HAND-WRITTEN FIXTURE. A person is not a product, so the gun never calls the API for a pop star's account; it works from the objects they sell. Captions below are our descriptions of public objects, NOT the real post captions. Counts are null because nothing here came from the API. Permalinks are null: a human confirms the actual post URL before any board is built.", + "profile": { + "username": "oliviarodrigo", + "name": "Olivia Rodrigo", + "kind": "creator", + "address_as": "Olivia", + "biography": null, + "website": "https://www.oliviarodrigo.com", + "followers_count": null, + "media_count": null, + "contact": "(management or label business contact from the official site; never a DM)" + }, + "media": [ + { + "id": "fixture-guts-vinyl", + "media_type": "IMAGE", + "fixture_kind": "product", + "product": "GUTS vinyl", + "caption": "Fixture: the GUTS LP, purple record half out of its sleeve, flat-lay on a plain surface. Sleeve art and lettering as published. New pressing, available in the official store.", + "permalink": null, + "media_url": null, + "like_count": null, + "comments_count": null, + "timestamp": "2026-06-01T00:00:00+0000", + "aspect": "1:1" + }, + { + "id": "fixture-sour-vinyl", + "media_type": "IMAGE", + "fixture_kind": "product", + "product": "SOUR vinyl", + "caption": "Fixture: the SOUR LP on a table, sleeve and record, product-only shot. Edition available in the shop.", + "permalink": null, + "media_url": null, + "like_count": null, + "comments_count": null, + "timestamp": "2025-11-01T00:00:00+0000", + "aspect": "4:5" + }, + { + "id": "fixture-tour-hoodie", + "media_type": "CAROUSEL_ALBUM", + "fixture_kind": "product", + "product": "GUTS World Tour hoodie", + "caption": "Fixture: tour merch flat-lay, hoodie and tee with the tour print, no model wearing them. Merch restock, link in bio.", + "permalink": null, + "media_url": null, + "like_count": null, + "comments_count": null, + "timestamp": "2026-03-01T00:00:00+0000", + "aspect": "4:5" + }, + { + "id": "fixture-guts-cd", + "media_type": "IMAGE", + "fixture_kind": "product", + "product": "GUTS CD", + "caption": "Fixture: the GUTS CD, jewel case and booklet, product-only.", + "permalink": null, + "media_url": null, + "like_count": null, + "comments_count": null, + "timestamp": "2025-09-01T00:00:00+0000", + "aspect": "1:1" + }, + { + "id": "fixture-portrait", + "media_type": "IMAGE", + "fixture_kind": "person", + "caption": "Fixture: a portrait of the artist. A person is never a generation input; this row exists to show the ranker demoting it.", + "permalink": null, + "media_url": null, + "like_count": null, + "comments_count": null, + "timestamp": "2026-07-01T00:00:00+0000", + "aspect": "4:5" + }, + { + "id": "fixture-concert-reel", + "media_type": "VIDEO", + "fixture_kind": "person", + "caption": "Fixture: on stage tonight with the crowd. A concert reel is people and motion, never a source.", + "permalink": null, + "media_url": null, + "like_count": null, + "comments_count": null, + "timestamp": "2026-08-01T00:00:00+0000", + "aspect": "9:16" + } + ] +} diff --git a/fuser-gun/ig-gun.mjs b/fuser-gun/ig-gun.mjs new file mode 100644 index 0000000000..02ccc43d44 --- /dev/null +++ b/fuser-gun/ig-gun.mjs @@ -0,0 +1,737 @@ +#!/usr/bin/env node +// ig-gun.mjs — the "Instagram gun" for Fuser ICP outreach. PROTOTYPE. +// +// Instead of starting from a studio's project page, start from a creator's or +// brand's PUBLIC Instagram, pick ONE product post we can reverse-engineer, and +// write the creator-first board brief, the client-story script and a DRAFT +// email in the shape the existing ICP lane already consumes. +// +// node fuser-gun/ig-gun.mjs scout [--as aesthetic] [--limit 25] [--top 12] [--fixture] [--no-media] +// node fuser-gun/ig-gun.mjs rank # re-rank from media.json, no network +// node fuser-gun/ig-gun.mjs brief [--hero ] [--product "Name"] [--voice stock|jeffrey] [--rank ] +// +// Default source: indexed public posts through Exa's keyless MCP search. +// --source graph opts into Business Discovery with Facebook Login credentials. +// --fixture is an explicit offline demo; failed live searches never use it. +// +// Credentials: /vault//instagram.env, _IG_USER_ID and +// _IG_TOKEN, env first, then the file. They are never printed, never +// written into out/. This file is public-safe. Node builtins only. +// +// House rules the output must fit (iris video/docs/ICP-BRIEF.md, HOUSE-STYLE.md): +// reverse-engineer ONE real product; products, never people or children; the +// label separate from the object; never Meshy-retexture lettering; cap 5,000 +// credits / floor 2,000; video at most 90 s; the voice quotes nothing verbatim +// and pads nothing. The gun ranks posts for "board-ability" with a cheap +// caption + media-type + aspect heuristic. It does NOT see pixels: a vision +// pass (a person, or a vision model) confirms the hero shows no face and a +// full, uncropped product before anything is built. + +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { normalizeSearchResults, searchInstagram } from "./public-search.mjs"; + +const here = import.meta.dirname; +const root = resolve(here, ".."); +const OUT = resolve(process.env.IG_GUN_OUT || join(here, "out")); +const FIXTURES = join(here, "fixtures"); + +const API_VERSION = "v25.0"; + +// Same registry as toolchain/instagram/ig.mjs: alias → env-var prefix. +const ACCOUNTS = { aesthetic: "AESTHETIC", whistlegraph: "WHISTLEGRAPH", oskiewar: "OSKIEWAR", menuband: "MENUBAND" }; + +// Budget and shape constants from ICP-BRIEF (2026-09-30/10-01). +const CREDIT_CAP = 5000; +const CREDIT_FLOOR = 2000; +const VIDEO_MAX_S = 90; +// Observed 2026-10-05: Pro 2K image, Meshy v7 60k PBR, Kling pro 4 s without audio. +// These are estimates for planning; `fuser-board.mjs quote` is the live price. +const PRICE = { image: 192.9, meshy: 1653.2, kling: 617.2 }; + +// The worker refreshes this shortlist against the live catalog before building. +// Hirad's ordering guidance is about families, not individual model variants. +const MODEL_SELECTION = { + catalog_url: "https://fuser.studio/models", + order_basis: "Most used by default; Hirad, relayed by Jeffrey on 2026-10-05", + policy: "Shortlist compatible families in most-used order, then choose by reference fidelity, required output and live price. Popularity is not a quality score; the new-models section is separate.", + requires_catalog_refresh: true, + record_before_generation: ["catalog check date", "observed family order", "exact variant", "selection reason", "live quote"], + proposed: { + image: { family: "Gemini Image", variant: "gemini-3-pro-image", reason: "Reference fidelity; no variant-level popularity claim" }, + mesh: { family: "Meshy", variant: "v7", reason: "Multi-view reconstruction" }, + video: { family: "Kling 3.0 Video", variant: "pro", reason: "Rigid-product push-in" }, + }, +}; +const IMAGE_INPUTS = { model: MODEL_SELECTION.proposed.image.variant, resolution: "2K", num_requests: 1, output_format: "image/png" }; + +// ── args ───────────────────────────────────────────────────────────── +const argv = process.argv.slice(2); +const flags = {}; +const positional = []; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith("--")) { flags[key] = next; i++; } else flags[key] = true; + } else positional.push(a); +} +const cmd = positional.shift(); +const handleArg = (positional.shift() || "").replace(/^@/, "").toLowerCase(); + +function die(msg) { console.error(`✗ ${msg}`); process.exit(1); } +const now = () => new Date().toISOString(); +const sha256 = (buf) => createHash("sha256").update(buf).digest("hex"); +const ensure = (dir) => { mkdirSync(dir, { recursive: true }); return dir; }; +const writeJSON = (path, data) => writeFileSync(path, JSON.stringify(data, null, 2) + "\n"); +const readJSON = (path) => JSON.parse(readFileSync(path, "utf8")); + +// ── credentials (never printed) ────────────────────────────────────── +function parseEnvFile(path) { + const out = {}; + for (const raw of readFileSync(path, "utf8").split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq < 0) continue; + out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim().replace(/^["']|["']$/g, ""); + } + return out; +} + +// Two flavours of the same API (found 2026-10-05, see README "API constraints"): +// instagram graph.instagram.com, "Instagram API with Instagram Login" — what every vault +// instagram.env holds today. Its IG User node has NO business_discovery edge. +// facebook graph.facebook.com, "Instagram API with Facebook Login" — a Page-linked IG +// professional account read with a Facebook Login user/page token. This is the +// one that carries business_discovery. Creds: _FB_IG_USER_ID + _FB_TOKEN +// in the same vault file (or env). Not provisioned yet on any account. +const HOSTS = { instagram: "https://graph.instagram.com", facebook: "https://graph.facebook.com" }; + +function loadAccount(alias, host = "instagram") { + const prefix = ACCOUNTS[alias]; + if (!prefix) return null; + const path = join(root, "vault", alias, "instagram.env"); + const file = existsSync(path) ? parseEnvFile(path) : {}; + const get = (k) => process.env[k] || file[k]; + const idKey = host === "facebook" ? `${prefix}_FB_IG_USER_ID` : `${prefix}_IG_USER_ID`; + const tokenKey = host === "facebook" ? `${prefix}_FB_TOKEN` : `${prefix}_IG_TOKEN`; + const igUserId = get(idKey); + const token = get(tokenKey); + if (!igUserId || !token) return null; + return { alias, host, igUserId, token }; +} + +// ── Graph API ──────────────────────────────────────────────────────── +const MEDIA_FIELDS = "id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,like_count,comments_count,timestamp"; +const PROFILE_FIELDS = "username,name,biography,website,followers_count,media_count,profile_picture_url"; + +function discoveryUrl(creds, handle, limit, { children = true } = {}) { + const media = `media.limit(${limit}){${MEDIA_FIELDS}${children ? ",children{id,media_type,media_url}" : ""}}`; + const fields = `business_discovery.username(${handle}){${PROFILE_FIELDS},${media}}`; + return `${HOSTS[creds.host]}/${API_VERSION}/${creds.igUserId}?fields=${encodeURIComponent(fields)}&access_token=${encodeURIComponent(creds.token)}`; +} + +// Error shape mirrors ig.mjs: message, code/subcode, user title/msg, fbtrace. +// Nothing here can carry a token, so it is safe to persist in api.json. +function apiError(status, body) { + const err = body?.error || body || {}; + return { + status, + message: err.message || null, + code: err.code ?? null, + error_subcode: err.error_subcode ?? null, + error_user_title: err.error_user_title || null, + error_user_msg: err.error_user_msg || null, + fbtrace_id: err.fbtrace_id || null, + }; +} + +async function callDiscovery(creds, handle, limit) { + const attempts = []; + for (const children of [true, false]) { + const url = discoveryUrl(creds, handle, limit, { children }); + const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + const body = await response.json().catch(() => ({})); + if (response.ok && body.business_discovery) { + attempts.push({ account: creds.alias, host: creds.host, children, ok: true, at: now() }); + return { ok: true, data: body.business_discovery, attempts }; + } + const error = apiError(response.status, body); + attempts.push({ account: creds.alias, host: creds.host, children, ok: false, error, at: now() }); + // Only a complaint about the children field is worth retrying without it; anything + // else (no edge, permission, token, target not a business account) is final here. + if (!(error.code === 100 && /children/i.test(error.message || ""))) break; + } + return { ok: false, attempts }; +} + +// ── ranking: board-ability ─────────────────────────────────────────── +// Positive: a product or object we can take apart. Negative: people, faces, +// children, events. This is a caption heuristic, so it is a sort, not a verdict. +const PEOPLE = /\b(me|my|myself|i'm|im|selfie|portrait|headshot|team|founders?|staff|crew|interview|meet|wearing|wears|wore|outfit|fit check|models?|fans?|concert|tour|tonight|on stage|onstage|backstage|birthday|wedding|baby|kids?|child|children|family|friends?|dog|cat|puppy|smile|face|hair|makeup|dancing|singing|crowd|audience|party|celebrat\w*)\b/gi; +const PRODUCT = /\b(new|launch(?:ed|ing)?|now available|available now|shop|restock(?:ed)?|pre-?order|edition|colou?rways?|collection|set|puzzle|vase|lamp|mug|cup|bowl|tray|clock|candle|blanket|pillow|poster|print|vinyl|lp|record|cd|cassette|merch|hoodie|tee|t-?shirt|crewneck|tote|box|tin|bottle|jar|pack(?:aging)?|object|designed by|made (?:of|in|by)|link in bio|gift|toy|blocks?|game|notebook|pen|pencil|tape|sticker|cover|sleeve)\b/gi; +const MINORS = /\b(kids?|child|children|baby|toddler|son|daughter|niece|nephew)\b/i; + +function countMatches(text, re) { re.lastIndex = 0; return (text.match(re) || []).length; } + +function aspectBucket(w, h) { + if (!w || !h) return null; + const r = w / h; + if (Math.abs(r - 1) < 0.06) return "1:1"; + if (Math.abs(r - 0.8) < 0.06) return "4:5"; + if (Math.abs(r - 1.91) < 0.12) return "1.91:1"; + if (r < 0.65) return "9:16"; + if (r > 1.2) return "landscape"; + return "portrait"; +} + +function scorePost(post, maxEngagement) { + const caption = post.caption || ""; + const reasons = []; + let score = 50; + + const type = post.media_type || "IMAGE"; + if (type === "IMAGE") { score += 15; reasons.push("still image +15"); } + else if (type === "CAROUSEL_ALBUM") { score += 10; reasons.push("carousel, first frame usable +10"); } + else if (type === "VIDEO") { score -= 20; reasons.push("video/reel needs a still, usually people −20"); } + + const people = Math.min(countMatches(caption, PEOPLE), 4); + if (people) { score -= 12 * people; reasons.push(`people words ×${people} −${12 * people}`); } + const product = Math.min(countMatches(caption, PRODUCT), 4); + if (product) { score += 8 * product; reasons.push(`product words ×${product} +${8 * product}`); } + if (MINORS.test(caption)) { score -= 60; reasons.push("mentions a minor: never a source −60"); } + + const engagement = post.like_count == null && post.comments_count == null ? null : (post.like_count || 0) + 3 * (post.comments_count || 0); + const eng = maxEngagement ? (engagement || 0) / maxEngagement : 0; + score += Math.round(15 * eng); + if (eng) reasons.push(`engagement ${(eng * 100).toFixed(0)}% of top +${Math.round(15 * eng)}`); + + if (post.timestamp) { + const days = (Date.now() - Date.parse(post.timestamp)) / 86_400_000; + if (days <= 180) { score += 5; reasons.push("recent +5"); } + } + + const aspect = post.aspect || aspectBucket(post.width, post.height); + if (aspect === "1:1" || aspect === "4:5") { score += 5; reasons.push(`${aspect} product frame +5`); } + else if (aspect === "9:16") { score -= 10; reasons.push("9:16 story/reel frame −10"); } + + if (post.fixture_kind === "person") { score -= 50; reasons.push("fixture marked as a person −50"); } + if (post.fixture_kind === "product") { score += 10; reasons.push("fixture marked as a product +10"); } + + return { score: Math.max(0, Math.min(100, score)), engagement, reasons, aspect: aspect || null }; +} + +// Pull a product name out of a caption: a quoted phrase, else a Title-Case run +// of 2–4 words that is not at the start of a sentence. Flagged for confirmation. +function guessProduct(caption) { + if (!caption) return null; + const quoted = caption.match(/(?:“([^”]{3,40})”|"([^"\n]{3,40})")/); + if (quoted) return (quoted[1] || quoted[2]).trim(); + const runs = caption.match(/(?<=[a-z,;:]\s)(?:[A-Z][\w'&-]+\s){1,3}[A-Z][\w'&-]+/g); + if (runs) { + const run = runs.find((r) => !/^(Link|Shop|Now|New|Follow|Tag|Happy)\b/.test(r)); + if (run) return run.trim(); + } + const first = caption.split(/[.!?\n]/)[0].trim(); + return first.length > 3 && first.length <= 48 ? first : null; +} + +function rankPosts(posts) { + const maxEng = Math.max(0, ...posts.map((p) => (p.like_count || 0) + 3 * (p.comments_count || 0))); + const ranked = posts.map((p) => { + const s = scorePost(p, maxEng); + return { + id: p.id, permalink: p.permalink, media_type: p.media_type, timestamp: p.timestamp, + like_count: p.like_count ?? null, comments_count: p.comments_count ?? null, + engagement: s.engagement, score: s.score, aspect: s.aspect, reasons: s.reasons, + product_guess: p.product || guessProduct(p.caption), caption_excerpt: (p.caption || "").slice(0, 140), + file: p.file || null, fixture_kind: p.fixture_kind || null, + }; + }).sort((a, b) => b.score - a.score || (b.engagement || 0) - (a.engagement || 0) || Date.parse(b.timestamp || 0) - Date.parse(a.timestamp || 0)); + ranked.forEach((r, i) => { r.rank = i + 1; }); + return ranked; +} + +// ── image headers (dimensions without a decoder) ───────────────────── +function imageSize(buf) { + if (buf.length > 24 && buf.readUInt32BE(0) === 0x89504e47) return { format: "png", width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; + if (buf.length > 4 && buf[0] === 0xff && buf[1] === 0xd8) { + let i = 2; + while (i + 9 < buf.length) { + if (buf[i] !== 0xff) { i++; continue; } + const marker = buf[i + 1]; + if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; } + const len = buf.readUInt16BE(i + 2); + if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) + return { format: "jpeg", height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) }; + i += 2 + len; + } + return { format: "jpeg" }; + } + if (buf.length > 30 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") { + const chunk = buf.toString("ascii", 12, 16); + if (chunk === "VP8X") return { format: "webp", width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) }; + if (chunk === "VP8L") { const b = buf.readUInt32LE(21); return { format: "webp", width: 1 + (b & 0x3fff), height: 1 + ((b >> 14) & 0x3fff) }; } + if (chunk === "VP8 ") return { format: "webp", width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff }; + } + return { format: "unknown" }; +} + +function stillUrl(post) { + if (post.media_type === "VIDEO") return post.thumbnail_url || null; + if (post.media_type === "CAROUSEL_ALBUM") { + const child = (post.children?.data || post.children || []).find((c) => c.media_type === "IMAGE" && c.media_url); + return child?.media_url || post.media_url || null; + } + return post.media_url || null; +} + +async function downloadStills(dir, posts, top) { + const mediaDir = ensure(join(dir, "media")); + const byEngagement = [...posts].sort((a, b) => ((b.like_count || 0) + 3 * (b.comments_count || 0)) - ((a.like_count || 0) + 3 * (a.comments_count || 0))).slice(0, top); + const index = []; + for (const post of byEngagement) { + const url = stillUrl(post); + if (!url) { index.push({ id: post.id, permalink: post.permalink, skipped: "no still url" }); continue; } + try { + const res = await fetch(url, { signal: AbortSignal.timeout(60_000) }); + if (!res.ok) { index.push({ id: post.id, permalink: post.permalink, skipped: `http ${res.status}` }); continue; } + const buf = Buffer.from(await res.arrayBuffer()); + const size = imageSize(buf); + if (size.format === "unknown") { index.push({ id: post.id, permalink: post.permalink, skipped: "response is not a supported image" }); continue; } + const ext = size.format === "png" ? "png" : size.format === "webp" ? "webp" : "jpg"; + const file = `${post.id}.${ext}`; + writeFileSync(join(mediaDir, file), buf); + post.file = `media/${file}`; post.width = size.width; post.height = size.height; + index.push({ file: post.file, id: post.id, source_url: url.split("?")[0], permalink: post.permalink, fetched_at: now(), + sha256: sha256(buf), width: size.width ?? null, height: size.height ?? null, bytes: buf.length, + attribution: post.discovery ? "Search-indexed Instagram thumbnail; source and crop need visual confirmation. Reference only; rights remain with the owner." : "Public Instagram post; rights remain with the account. Reference only, never presented as our work." }); + process.stderr.write(` ↓ ${post.id} ${size.width}×${size.height}\n`); + } catch (e) { + index.push({ id: post.id, permalink: post.permalink, skipped: e.message }); + } + } + writeJSON(join(dir, "media-index.json"), index); + const csv = ["file,source_url,page_url,fetched_at,attribution,width,height"]; + for (const m of index) if (m.file) csv.push([m.file, m.source_url, m.permalink, m.fetched_at, `"${m.attribution}"`, m.width, m.height].join(",")); + writeFileSync(join(dir, "media.csv"), csv.join("\n") + "\n"); + return index; +} + +// ── fixtures ───────────────────────────────────────────────────────── +function loadFixture(handle) { + const path = join(FIXTURES, `${handle}.json`); + if (!existsSync(path)) return null; + const f = readJSON(path); + f.profile = { ...f.profile, username: f.profile?.username || handle, fixture: true }; + f.media = (f.media || []).map((m, i) => ({ id: m.id || `fixture-${handle}-${i + 1}`, ...m, fixture: true })); + return f; +} + +// ── scout ──────────────────────────────────────────────────────────── +async function scout(handle) { + if (!handle) die("scout needs a handle"); + const dir = ensure(join(OUT, handle)); + const limit = Number(flags.limit || 25); + const top = Number(flags.top || 12); + if (!Number.isInteger(limit) || limit < 1 || limit > 100 || !Number.isInteger(top) || top < 1 || top > 100) die("--limit and --top must be integers from 1 to 100"); + const source = flags.fixture ? "fixture" : flags["search-results"] ? "search" : flags.source || (flags.host || flags.as ? "graph" : "exa"); + if (!["fixture", "search", "graph", "exa"].includes(source)) die("--source must be exa or graph; use --fixture for a demo"); + const api = { handle, requested_at: now(), attempts: [], source: null, note: null }; + + let profile = null, media = null; + if (source === "exa" || source === "search") { + process.stderr.write(`→ public Instagram search @${handle} via ${source === "exa" ? "Exa" : "saved results"}\n`); + const evidence = source === "exa" ? await searchInstagram(handle, limit, flags.query ? String(flags.query) : undefined) : readJSON(resolve(String(flags["search-results"]))); + const normalized = normalizeSearchResults(handle, evidence); + if (!normalized.media.length) { + writeJSON(join(dir, "search-failed.json"), { evidence, excluded: normalized.excluded }); + die(`no posts attributable to @${handle} in search evidence; see search-failed.json. Previous successful output retained; no fixture substituted.`); + } + writeJSON(join(dir, "search.json"), evidence); + writeJSON(join(dir, "search-excluded.json"), normalized.excluded); + profile = normalized.profile; media = normalized.media; + api.source = source === "exa" ? "exa" : "public-search"; + api.note = "Indexed public posts, not a complete or current feed. Counts, post dates and exact media types are unknown. Search thumbnails need visual confirmation."; + api.attempts.push({ provider: evidence.provider || source, query: evidence.query || null, retrieved_at: evidence.retrieved_at || now(), ok: true, returned: evidence.results.length, accepted: media.length }); + } + if (source === "graph") { + api.version = API_VERSION; api.edge = "business_discovery"; + const aliases = flags.as ? [String(flags.as)] : Object.keys(ACCOUNTS); + const hosts = flags.host ? [String(flags.host)] : ["facebook"]; + if (hosts.some((host) => !HOSTS[host])) die("--host must be facebook or instagram"); + outer: for (const host of hosts) { + for (const alias of aliases) { + const creds = loadAccount(alias, host); + if (!creds) { api.attempts.push({ account: alias, host, ok: false, error: { message: "not provisioned on this host" } }); continue; } + process.stderr.write(`→ business_discovery @${handle} as @${alias} via ${HOSTS[host]}\n`); + const r = await callDiscovery(creds, handle, limit); + api.attempts.push(...r.attempts); + if (r.ok) { + const { media: m, ...p } = r.data; + profile = p; media = m?.data || []; + api.source = "graph"; api.account = alias; api.host = HOSTS[host]; + break outer; + } + const last = r.attempts.at(-1)?.error; + process.stderr.write(` ✗ ${last?.status} ${last?.message || ""} (code ${last?.code}/${last?.error_subcode ?? "-"})\n`); + if (flags.as && flags.host) break outer; + } + } + } + + if (source === "fixture") { + const fixture = loadFixture(handle); + if (!fixture) { + writeJSON(join(dir, "api.json"), api); + die(`no fixture at fixtures/${handle}.json`); + } + profile = fixture.profile; media = fixture.media; + api.source = "fixture"; api.fixture_path = `fixtures/${handle}.json`; + api.note = "fixture explicitly requested; no API call made"; + process.stderr.write(`→ using fixture for @${handle} (${media.length} items)\n`); + } + if (!profile) { + writeJSON(join(dir, "graph-error.json"), api); + die(`no Business Discovery result for @${handle}; use --source exa for public search. No fixture substituted.`); + } + + // A new scout invalidates drafts built from a previous hero or data source. + for (const path of ["brief.md", "script.txt", "email.md", "board-spec.json", "icp-queue.line", "provenance.json", "data"]) + rmSync(join(dir, path), { recursive: true, force: true }); + + api.counts = { media_returned: media.length, profile_followers: profile.followers_count ?? null, profile_media_count: profile.media_count ?? null }; + writeJSON(join(dir, "profile.json"), { ...profile, fetched_at: now(), source: api.source }); + writeJSON(join(dir, "media.json"), media); + + if (!flags["no-media"] && api.source !== "fixture") await downloadStills(dir, media, top); + else { + writeJSON(join(dir, "media-index.json"), []); + writeFileSync(join(dir, "media.csv"), "file,source_url,page_url,fetched_at,attribution,width,height\n"); + } + writeJSON(join(dir, "media.json"), media); // now with file/width/height + + const ranking = rankPosts(media); + writeJSON(join(dir, "ranking.json"), { + handle, ranked_at: now(), source: api.source, + note: "Caption + media-type + aspect heuristic. A vision pass (person or vision model) picks the hero: it must show no face, no minor, and the FULL uncropped product.", + hero: ranking.find((r) => r.media_type !== "VIDEO") || ranking[0] || null, + posts: ranking, + }); + writeJSON(join(dir, "api.json"), api); + + console.log(`✓ @${handle} via ${api.source}${api.account ? ` (@${api.account})` : ""}: ${media.length} posts, ${profile.followers_count ?? "?"} followers, ${profile.media_count ?? "?"} media`); + for (const r of ranking.slice(0, 5)) console.log(` #${r.rank} ${String(r.score).padStart(3)} ${r.media_type?.padEnd(14)} ${r.permalink || "(fixture)"} ${r.product_guess || ""}`); + console.log(` → ${dir}`); +} + +function rank(handle) { + const dir = join(OUT, handle); + if (!existsSync(join(dir, "media.json"))) die(`scout @${handle} first`); + const media = readJSON(join(dir, "media.json")); + const ranking = rankPosts(media); + const prior = existsSync(join(dir, "ranking.json")) ? readJSON(join(dir, "ranking.json")) : {}; + writeJSON(join(dir, "ranking.json"), { ...prior, handle, ranked_at: now(), hero: ranking.find((r) => r.media_type !== "VIDEO") || ranking[0] || null, posts: ranking }); + for (const r of ranking.slice(0, 10)) console.log(` #${r.rank} ${String(r.score).padStart(3)} ${r.media_type?.padEnd(14)} ${r.permalink || "(fixture)"} ${r.product_guess || ""}`); +} + +// ── brief ──────────────────────────────────────────────────────────── +// What we grow from the product, by category word. One new sibling in their +// system; the lettering and artwork stay exactly as published. +function variantFor(product, caption, hints = {}) { + const base = categoryVariant(product, caption); + // A fixture or a worker may override any field; the rest comes from the category. + const picked = Object.fromEntries(["variant", "keep", "blank", "lift", "stage"].filter((k) => hints[k]).map((k) => [k, hints[k]])); + return { ...base, ...picked }; +} + +function categoryVariant(product, caption) { + const text = `${product || ""} ${caption || ""}`.toLowerCase(); + // variant: what Nano Banana Pro draws · keep: what stays exact (short, spoken) · blank: the carrier + // lift: the "label off the bottle" line · stage: their own product-only staging to match + if (/vinyl|\blp\b|record/.test(text)) return { variant: "a new pressing colour", keep: "your sleeve", blank: "the disc", lift: "Next, it sets the sleeve aside and keeps the plain disc.", stage: "your flat-lay, half out of the sleeve" }; + if (/puzzle/.test(text)) return { variant: "a new gradient", keep: "your box", blank: "the pieces", lift: "Next, it takes the print off the pieces.", stage: "your top-down shot" }; + if (/hoodie|tee|t-shirt|crewneck|shirt|tote/.test(text)) return { variant: "a new colourway", keep: "your print", blank: "the garment", lift: "Next, it lifts the print off the garment.", stage: "your flat-lay" }; + if (/candle|vase|lamp|mug|cup|bowl|clock|tray|toy|blocks?|robot/.test(text)) return { variant: "a new colourway", keep: "the form and every mark", blank: "the body", lift: "Next, it takes the marks off the body.", stage: "your tabletop shot" }; + if (/poster|print|cover|sleeve|book|zine/.test(text)) return { variant: "a new edition colour", keep: "your artwork", blank: "the paper", lift: "Next, it lifts the artwork off the paper.", stage: "your wall shot" }; + if (/bottle|jar|tin|box|pack/.test(text)) return { variant: "a new sibling in the range", keep: "your label and lettering", blank: "the container", lift: "Next, it takes the label off the container.", stage: "your product-only shot" }; + return { variant: "a new colourway", keep: "every mark and word", blank: "the object", lift: "Next, it takes the marks off the object.", stage: "your own product shot" }; +} + +function displayName(profile) { + const n = profile.name || profile.username; + return n.replace(/\s*\|.*$/, "").trim(); +} + +function spokenName(profile) { + // The script addresses the account by its public name ("Areaware", "Olivia"), + // first word for a person-named creator account, whole name for a brand. + const name = displayName(profile); + return profile.address_as || (profile.kind === "creator" ? name.split(" ")[0] : name); +} + +function scriptLines({ who, product, v, voice }) { + const opener = voice === "jeffrey" + ? `Hey ${who}, this is @jeffrey from Fuser. I checked out your Instagram and made a variation of your ${product}.` + : `Hey ${who}, this is Fuser. We checked out your Instagram and made a variation of your ${product}.`; + // 14h: one short sentence per step, the model named once where it is pressed, + // no hedges in the voice (provenance lives in the receipt). 70–100 words. + return [ + opener, + "It starts from your photo.", + `Nano Banana Pro draws ${v.variant}, and keeps ${v.keep} exactly as it is.`, + v.lift, + "Then it makes the side and back views.", + "Meshy turns them into a 3D model.", + `We stage it like ${v.stage}.`, + "And Kling gives it a slow push-in.", + `So here's your original ${product}, and a new one grown from it!`, + "Give it a try at fuser.studio", + ]; +} + +function wordCount(lines) { return lines.join(" ").split(/\s+/).filter(Boolean).length; } + +function brief(handle) { + const dir = join(OUT, handle); + for (const f of ["profile.json", "media.json", "ranking.json"]) if (!existsSync(join(dir, f))) die(`scout @${handle} first (missing ${f})`); + const profile = readJSON(join(dir, "profile.json")); + const media = readJSON(join(dir, "media.json")); + const ranking = readJSON(join(dir, "ranking.json")); + const api = existsSync(join(dir, "api.json")) ? readJSON(join(dir, "api.json")) : {}; + + const heroRank = flags.hero ? Number(flags.hero) : null; + const hero = heroRank ? ranking.posts.find((p) => p.rank === heroRank) : ranking.hero; + if (!hero) die("no hero post in ranking.json"); + if (hero.media_type === "VIDEO") die(`hero #${hero.rank} is a video; pick a still with --hero `); + if (hero.fixture_kind === "person" || MINORS.test(hero.caption_excerpt)) die(`hero #${hero.rank} reads as a person; the gun never builds from a person. Pick another with --hero .`); + const post = media.find((m) => m.id === hero.id) || {}; + + const who = spokenName(profile); + const brand = displayName(profile); + const product = flags.product || post.product || hero.product_guess || "hero object"; + const productConfirmed = Boolean(flags.product || post.product); + const v = variantFor(product, post.caption, post); + const voice = flags.voice === "jeffrey" ? "jeffrey" : "stock"; + const lines = scriptLines({ who, product, v, voice }); + // The script speaks to them ("your box"); the brief and the prompts speak about them. + const theirs = (s) => s.replace(/^your /, "their "); + const theObj = (s) => s.replace(/^your /, "the "); + const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1); + const blankName = `Blank ${cap(v.blank.replace(/^the /, ""))}`; + const newName = `New ${product}`; + const refName = `${product} · @${profile.username}`; + const words = wordCount(lines); + const slug = handle.replace(/[^a-z0-9]+/g, "-"); + const rankNo = flags.rank || "000"; + const refFile = hero.file || `reference/${slug}-hero.jpg`; + const today = now().slice(0, 10); + const permalink = hero.permalink || "(fixture: confirm the post URL by hand)"; + const searchSource = ["exa", "public-search"].includes(api.source); + const sourceDescription = api.source === "graph" + ? `Business Discovery via ${api.host} ${api.version}, read as @${api.account}` + : searchSource ? `${api.source} indexed public search; see search.json for the retrieved evidence. This is not a complete feed; engagement, post dates and media types remain unknown` + : `hand-written fixture ${api.fixture_path || ""}; no live post evidence`; + const sourceStatus = api.source === "graph" ? `solid: Graph API at ${profile.fetched_at || today}` + : searchSource ? "soft: indexed public search; confirm against the post" : "soft: hand-written fixture"; + const imageStatus = hero.file ? (searchSource ? "Downloaded search thumbnail; confirm media type, full product and crop before generation." : "Downloaded reference; visual confirmation required.") : "Reference image missing; retrieve and inspect the source before generation."; + + const budget = [ + ["Variant (Nano Banana Pro)", PRICE.image], ["Lift the blank object (Nano Banana Pro)", PRICE.image], + ["Side view (Nano Banana Pro)", PRICE.image], ["Back view (Nano Banana Pro)", PRICE.image], + ["3D (Meshy 7 multi-image, read price live)", PRICE.meshy], ["Staged shot (Nano Banana Pro)", PRICE.image], + ["Push-in (Kling 3.0 pro, 4 s)", PRICE.kling], + ]; + const estimate = budget.reduce((s, [, c]) => s + c, 0); + const retries = 2 * PRICE.image + PRICE.kling; + + const top3 = ranking.posts.slice(0, 3); + + const briefMd = `# ${brand} · ${product} — creator-first ICP brief (prototype) + +Generated ${today} by \`fuser-gun/ig-gun.mjs brief ${handle}\` from the public Instagram of @${profile.username} +(${sourceDescription}). +Same shape as \`video/docs/ICP-BRIEF.md\`, read as the worker would: TOP-DOWN from ONE real post. + +## Client + +- Account: @${profile.username}${profile.name ? ` · ${profile.name}` : ""}${profile.kind ? ` · ${profile.kind}` : ""} +- Followers ${profile.followers_count ?? "n/a"} · media ${profile.media_count ?? "n/a"}${profile.website ? ` · ${profile.website}` : ""} +- Bio: ${(profile.biography || "").replace(/\s+/g, " ").trim() || "n/a"} +- icp-queue line: \`${rankNo}|${brand}|${slug}\` + +## The source post (TOP-DOWN, 14d) + +- Post: ${permalink} +- Reference status: ${imageStatus} +- Posted ${hero.timestamp || "n/a"} · ${hero.media_type || "IMAGE"} · likes ${hero.like_count ?? "n/a"} · comments ${hero.comments_count ?? "n/a"} · aspect ${hero.aspect || "n/a"} +- Board-ability ${hero.score}/100 (${hero.reasons.join("; ")}) +- Caption: ${(post.caption || "").replace(/\s+/g, " ").slice(0, 300) || "n/a"} +- Reference file: \`${refFile}\` — credited on the board's first node as "${product} · @${profile.username}", URL in \`reference/provenance.json\`. +- Product: **${product}**${productConfirmed ? "" : " (guessed from the caption; confirm before building, `--product \"Name\"`)"}. + +**Vision pass before anything is built (hard gate):** the hero shows no face, no minor, and the FULL uncropped +product (FULL PRODUCT FIRST). If the post crops it, add the account's own full view, or the maker's official +product shot, as a second reference. If no post of theirs shows the product clean, block with that reason; +never draw a stand-in. + +## What we grow from it (reverse-engineer ONE real product) + +- Original: their ${product}, as published, credited. +- Parts: ${v.keep.replace(/^your /, "their ")} (kept exact) separate from ${v.blank} (the carrier). +- New sibling: ${v.variant} of the same ${product}. Same type style, layout and palette logic. Spelled correctly. Recorded in the receipt + as our exploration in their style, never their artwork. The voice never says so. +- Rebuild: put their artwork back onto the recoloured ${v.blank.replace(/^the /, "")} in 2D, where the lettering stays exact. +- 3D: side and back views checked against the front, into Meshy 7 multi-image. If the lettering smears, the 3D + is the form only; say so in the receipt. Never retexture lettering. +- Staging: match ${theirs(v.stage)} (set, light, shadow), theirs beside ours in the fidelity frame. +- Film: Kling 3.0 pro, 4 s, push-in, rigid and simple. + +## Node chain (board spec in \`board-spec.json\`) + +| # | node | type | input | output | +|---|---|---|---|---| +| 0 | ${refName} | ImageNode | \`${refFile}\` | the original | +| 1 | ${newName} | FalGeminiImageNode (Nano Banana Pro) | 0 | ${v.variant}, artwork kept | +| 2 | ${blankName} | FalGeminiImageNode | 0 | ${v.blank} with the artwork lifted off | +| 3 | Side View · Back View | FalGeminiImageNode ×2 | 1 | views for 3D, checked against the front | +| 4 | 3D Model | FalMeshyNode v7 multi-image | 1, 3 | the form (texture ref = 1; lettering never retextured) | +| 5 | Staged Shot | FalGeminiImageNode | 1, 0 | ours in their staging | +| 6 | Push In | FalKling30VideoNode pro 4 s | 5 | the clip | + +Stages: [1, 2] → [3] → [4, 5] → [6]. Cheap first, inspect, fidelity frame per node (\`evidence/fidelity-.png\`). + +Refresh [Fuser Models](${MODEL_SELECTION.catalog_url}) before building: its default order is most used +(Hirad, relayed by Jeffrey on 2026-10-05). Shortlist compatible families in that order, then choose +by reference fidelity, output needs and live price. Record the check date, observed order, exact +variant and reason in \`board-spec.json\`. Family order does not rank variants; new models are separate. + +## Budget + +Cap **${CREDIT_CAP.toLocaleString()}** credits for this client, Iris balance never below **${CREDIT_FLOOR.toLocaleString()}**. +Estimate ${estimate.toLocaleString()} (${budget.map(([n, c]) => `${n} ${c}`).join(" · ")}), plus up to ${retries} for two retries. +\`fuser-board.mjs quote\` is the price; read it before every press. This brief spends nothing. + +## Acceptance + +Per node: not cheaper or different than the post next to it (flat, wrong material, lost detail, altered lettering = fail). +Two fidelity failures drop the branch and the story goes on without it. What is kept (${v.keep.replace(/^your /, "their ")}) matches the post exactly. + +## Video (≤ ${VIDEO_MAX_S} s, HOUSE-STYLE 14h adapted for a creator) + +Opens on the finished board zoomed out. Every press filmed on its own node. Say it, show it. Compare before the CTA. +Script: \`script.txt\` (${lines.length} lines, ${words} words, ${voice === "jeffrey" ? "@jeffrey PVC" : "stock voice, so the opener says \"this is Fuser\""}; models named once at their press). +Spoken CTA "Give it a try at Fuser dot studio"; caption "Give it a try at fuser.studio", no period, no captions on the last frame. + +## Other posts the gun ranked + +${top3.map((p) => `${p.rank}. ${p.score}/100 · ${p.media_type} · ${p.permalink || "(fixture)"} · ${p.product_guess || ""}`).join("\n")} + +## What this is not + +Not verified with the account. Not a commitment. The post is reference only; rights stay with @${profile.username}. +No DM, no comment, no tag, no Instagram write of any kind. Email in \`email.md\` is a DRAFT and is not sent from here. +`; + + const emailMd = `# DRAFT — not sent + +From: Jeffrey +To: ${profile.contact || "(business contact from their website or bio; Business Discovery exposes no email)"} +Subject: A variation of your ${product}, made in Fuser + +Hi ${who}, + +We checked out your Instagram and made a short piece around your ${product}: your post as the starting point, then ${v.variant}, a 3D model of it, and a four-second push-in, all on one Fuser board. + +The video is attached, under ${Math.min(VIDEO_MAX_S, 75)} seconds. ${v.keep.replace(/^your /, "Your ")} is untouched; the new one is our exploration in your style. + +If a board like this fits how you make things, I can set one up for you to try. + +Jeffrey +Fuser · fuser.studio + +Attachment: ${slug}-client-story.mp4 (after render and approval). Send-readiness: needs approval (first contact, attachment). +`; + + const spec = { + title: `${brand} · ${product}`, + description: `Creator-first ICP board grown from ${permalink}. Reference credited on node 0. Prototype spec from fuser-gun; prompts to be tuned by the worker after the vision pass.`, + budget: CREDIT_CAP, + model_selection: MODEL_SELECTION, + nodes: [ + { name: refName, type: "ImageNode", file: refFile, annotation: `${permalink} — ${searchSource ? "search-indexed candidate; visual confirmation required" : "reference; visual confirmation required"}` }, + { name: newName, type: "FalGeminiImageNode", inputs: { ...IMAGE_INPUTS, prompt: `Using this exact ${product} as the only reference, make ${v.variant}. Keep ${theObj(v.keep)} exactly as shown, every letter and mark identical. Same light, same background, same camera.`, aspect_ratio: hero.aspect === "4:5" ? "4:5" : "1:1" }, wires: { image: [refName] } }, + { name: blankName, type: "FalGeminiImageNode", inputs: { ...IMAGE_INPUTS, prompt: `Show the same ${product} with all printed artwork and lettering removed, leaving only ${v.blank}, plain. Same light, same angle.`, aspect_ratio: hero.aspect === "4:5" ? "4:5" : "1:1" }, wires: { image: [refName] } }, + { name: "Side View", type: "FalGeminiImageNode", inputs: { ...IMAGE_INPUTS, prompt: `The same object from the first image, exact side view, same proportions, same colours, plain background.`, aspect_ratio: "1:1" }, wires: { image: [newName] } }, + { name: "Back View", type: "FalGeminiImageNode", inputs: { ...IMAGE_INPUTS, prompt: `The same object from the first image, exact back view, same proportions, same colours, plain background.`, aspect_ratio: "1:1" }, wires: { image: [newName] } }, + { name: "3D Model", type: "FalMeshyNode", inputs: { version: MODEL_SELECTION.proposed.mesh.variant, target_polycount: 60000, enable_pbr: true }, wires: { image: [newName, "Side View", "Back View"], texture_image_url: [newName] } }, + { name: "Staged Shot", type: "FalGeminiImageNode", inputs: { ...IMAGE_INPUTS, prompt: `Place the object from the first image in the setting of the second image. Match light, shadow and lens. Nothing else changes.`, aspect_ratio: hero.aspect === "4:5" ? "4:5" : "1:1" }, wires: { image: [newName, refName] } }, + { name: "Push In", type: "FalKling30VideoNode", inputs: { prompt: "Locked-off product shot. The camera pushes in slowly. Nothing else moves.", model: MODEL_SELECTION.proposed.video.variant, duration: 4, generate_audio: false }, wires: { image: ["Staged Shot"] } }, + ], + stages: [[newName, blankName], ["Side View", "Back View"], ["3D Model", "Staged Shot"], ["Push In"]], + }; + + // Dossier-shaped data/ so the existing lane's tooling reads it unchanged. + const data = ensure(join(dir, "data")); + const solid = sourceStatus; + writeFileSync(join(data, "profile.csv"), ["field,value,status,source_url", + `username,@${profile.username},${solid},https://www.instagram.com/${profile.username}/`, + `name,"${(profile.name || "").replace(/"/g, "'")}",${solid},https://www.instagram.com/${profile.username}/`, + `followers_count,${profile.followers_count ?? ""},${profile.followers_count != null ? solid : "missing"},https://www.instagram.com/${profile.username}/`, + `media_count,${profile.media_count ?? ""},${profile.media_count != null ? solid : "missing"},https://www.instagram.com/${profile.username}/`, + `website,${profile.website || ""},${profile.website ? "solid: profile field" : "missing"},https://www.instagram.com/${profile.username}/`].join("\n") + "\n"); + writeFileSync(join(data, "projects.csv"), ["project,year,medium_and_relevance,source_urls,image_file,status", + ...ranking.posts.filter((p) => p.media_type !== "VIDEO").slice(0, 8).map((p) => `"${(p.product_guess || "post").replace(/"/g, "'")}",${(p.timestamp || "").slice(0, 4)},"Instagram ${p.media_type}; board-ability ${p.score}/100",${p.permalink || ""},${p.file || ""},${sourceStatus}`)].join("\n") + "\n"); + writeFileSync(join(data, "signals.csv"), ["signal,observed_date,source_urls,status", + ...ranking.posts.slice(0, 8).map((p) => `"${p.caption_excerpt.replace(/"/g, "'").replace(/\n/g, " ")}",${(p.timestamp || "").slice(0, 10)},${p.permalink || ""},${sourceStatus}; engagement ${p.engagement ?? "unknown"}`)].join("\n") + "\n"); + writeFileSync(join(data, "fuser-fit.md"), briefMd); + writeFileSync(join(data, "outreach.md"), emailMd); + writeFileSync(join(data, "README.md"), `# Research status\n\nSource: ${sourceDescription}. Retrieved ${profile.fetched_at || today}.\n\n${imageStatus}\n\nThe product name "${product}" ${productConfirmed ? "was supplied" : "is guessed from the indexed caption"}. Ranking uses text and image dimensions; it does not inspect pixels.\n\nMissing: verified business contact, who makes what for the account, rights to the design, any prior Fuser contact. No generation or outreach performed.\n`); + + writeFileSync(join(dir, "brief.md"), briefMd); + writeFileSync(join(dir, "script.txt"), lines.join("\n") + "\n"); + writeFileSync(join(dir, "email.md"), emailMd); + writeJSON(join(dir, "board-spec.json"), spec); + writeFileSync(join(dir, "icp-queue.line"), `${rankNo}|${brand}|${slug}\n`); + writeJSON(join(dir, "provenance.json"), { reference: [{ file: hero.file || null, permalink: hero.permalink, source: api.source, discovery: post.discovery || null, credit: `${brand} (@${profile.username})`, note: imageStatus, needs_visual_confirmation: true }], generated_at: now() }); + + console.log(`✓ brief for @${handle}: hero #${hero.rank} ${permalink}`); + console.log(` product "${product}"${productConfirmed ? "" : " (guessed; confirm)"} → ${v.variant}`); + console.log(` script ${lines.length} lines / ${words} words (${voice}) · estimate ${estimate} cr of cap ${CREDIT_CAP}`); + console.log(` → ${dir}/{brief.md,script.txt,email.md,board-spec.json,icp-queue.line,data/}`); +} + +// ── main ───────────────────────────────────────────────────────────── +function help(code = 0) { + console.log(`ig-gun.mjs — Instagram gun for Fuser ICP outreach (prototype; read-only) + +usage: + node fuser-gun/ig-gun.mjs scout [--source exa|graph] [--query "search terms"] [--limit 25] [--top 12] [--no-media] + node fuser-gun/ig-gun.mjs scout --search-results results.json + node fuser-gun/ig-gun.mjs scout --fixture + node fuser-gun/ig-gun.mjs rank + node fuser-gun/ig-gun.mjs brief [--hero ] [--product "Name"] [--voice stock|jeffrey] [--rank ] + +scout searches public Instagram posts through Exa (no token). Saves search evidence, post + permalinks, available thumbnails and rankings in out//. Search counts and dates + remain unknown. --source graph opts into Business Discovery (--as account / --host + facebook|instagram). --fixture explicitly uses a demo; no automatic fixture fallback. +brief writes brief.md (ICP-BRIEF shape), script.txt (HOUSE-STYLE 14h, creator form), email.md (DRAFT), + board-spec.json (fuser-board.mjs spec), icp-queue.line and a dossier-shaped data/. +Spends no credits, builds no board, sends nothing, writes nothing to Instagram.`); + process.exit(code); +} + +if (!cmd || flags.help || cmd === "help") help(cmd ? 0 : 1); +if (!/^[a-z0-9_][a-z0-9_.]{0,29}$/.test(handleArg) || handleArg.includes("..")) die("use an Instagram handle, not a path or URL"); +try { + if (cmd === "scout") await scout(handleArg); + else if (cmd === "rank") rank(handleArg); + else if (cmd === "brief") brief(handleArg); + else help(1); +} catch (e) { + die(e.message || String(e)); +} diff --git a/fuser-gun/public-search.mjs b/fuser-gun/public-search.mjs new file mode 100644 index 0000000000..4a485f7d53 --- /dev/null +++ b/fuser-gun/public-search.mjs @@ -0,0 +1,98 @@ +// Public Instagram discovery through Exa's keyless, rate-limited MCP endpoint. +// Search evidence is not a complete feed or an authenticated Graph response. +const ENDPOINT = "https://mcp.exa.ai/mcp?tools=web_search_advanced_exa"; + +export async function searchInstagram(handle, limit = 25, query = `"${handle}" Instagram product launch collection`) { + const response = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, + body: JSON.stringify({ + jsonrpc: "2.0", id: 1, method: "tools/call", + params: { name: "web_search_advanced_exa", arguments: { + query, includeDomains: ["instagram.com"], + numResults: limit, textMaxCharacters: 1500, type: "auto", + } }, + }), + signal: AbortSignal.timeout(55_000), + }); + if (!response.ok) throw new Error(`Exa search HTTP ${response.status}; no fixture substituted`); + const text = await response.text(); + const messages = response.headers.get("content-type")?.includes("text/event-stream") + ? text.split(/\r?\n\r?\n/).map((event) => event.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n")).filter(Boolean).map((data) => JSON.parse(data)) + : [JSON.parse(text)]; + const rpc = messages.find((message) => message.id === 1); + if (!rpc?.result || rpc.error || rpc.result.isError) throw new Error("Exa search failed; no fixture substituted"); + let data; + for (const item of rpc.result.content || []) { + if (item.type !== "text") continue; + try { const value = JSON.parse(item.text); if (Array.isArray(value.results)) data = value; } catch {} + } + if (!data) throw new Error("Exa returned no structured results; no fixture substituted"); + return { provider: "exa", query, retrieved_at: new Date().toISOString(), ...data }; +} + +function instagramURL(value) { + try { + const url = new URL(value); + if (url.protocol !== "https:" || !["instagram.com", "www.instagram.com"].includes(url.hostname)) return null; + return url; + } catch { return null; } +} + +function imageURL(value) { + try { + const url = new URL(value); + if (url.protocol !== "https:" || !["cdninstagram.com", "fbcdn.net"].some((host) => url.hostname === host || url.hostname.endsWith(`.${host}`))) return null; + return url.href; + } catch { return null; } +} + +const identity = (value) => String(value || "").trim().replace(/^@/, "").toLowerCase(); + +export function normalizeSearchResults(handle, data) { + if (!Array.isArray(data.results)) throw new Error("Search results must contain a results array"); + const media = [], excluded = [], seen = new Set(); + const profile = { username: handle, name: handle, biography: null, website: null, followers_count: null, media_count: null }; + for (const result of data.results) { + const url = instagramURL(result.url); + if (!url) { excluded.push({ url: result.url, reason: "not a public Instagram URL" }); continue; } + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length === 1 && identity(parts[0]) === handle) { + profile.permalink = `https://www.instagram.com/${handle}/`; + const match = (result.title || "").match(/^(.+?)\s*\(@[^)]+\)/); + if (match) profile.name = match[1].trim(); + continue; + } + const hasOwner = parts.length === 3; + const [kind, shortcode] = hasOwner ? parts.slice(1) : parts; + if (![2, 3].includes(parts.length) || !["p", "reel", "tv"].includes(kind) || !/^[A-Za-z0-9_-]+$/.test(shortcode || "")) { + excluded.push({ url: result.url, reason: "not a post permalink" }); continue; + } + const titleOwner = (result.title || "").match(/^(.+?) on Instagram:/i)?.[1] || (result.title || "").match(/^([^|]+)\s*\|/)?.[1]; + const textOwner = (result.text || "").match(/^([^|\n]+)\s*\|/)?.[1]; + // A mention of @handle inside another account's caption is not authorship. + const evidence = hasOwner + ? (identity(parts[0]) === handle ? "account in URL" : null) + : [result.author, titleOwner, textOwner].some((name) => identity(name) === handle) ? "account in indexed author/title" : null; + if (!evidence) { excluded.push({ url: result.url, reason: "target authorship not established by search evidence" }); continue; } + if (seen.has(shortcode)) continue; + seen.add(shortcode); + if (profile.name === handle && identity(titleOwner) === handle) profile.name = titleOwner.trim(); + const caption = (result.title || "").replace(/^.+? on Instagram:\s*/i, "").replace(/^[^|]+\s*\|\s*/, "").replace(/^["“]|["”]$/g, "").trim(); + media.push({ + id: shortcode, permalink: `https://www.instagram.com/${kind}/${shortcode}/`, + caption, caption_kind: "indexed title excerpt", + media_type: kind === "p" ? "UNKNOWN" : "VIDEO", + media_url: kind === "p" ? imageURL(result.image) : null, + thumbnail_url: kind !== "p" ? imageURL(result.image) : null, + like_count: null, comments_count: null, timestamp: null, + discovery: { + provider: data.provider || "public-search", query: data.query || null, + retrieved_at: data.retrieved_at || null, owner_evidence: evidence, + indexed_url: result.url, indexed_published_date: result.publishedDate || null, + image_kind: "search thumbnail; inspect crop and source before generation", + }, + }); + } + return { profile, media, excluded }; +} diff --git a/fuser-gun/public-search.test.mjs b/fuser-gun/public-search.test.mjs new file mode 100644 index 0000000000..e7b79822fc --- /dev/null +++ b/fuser-gun/public-search.test.mjs @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { normalizeSearchResults, searchInstagram } from "./public-search.mjs"; + +const result = (url, title, rest = {}) => ({ url, title, ...rest }); + +test("accepts indexed authorship, deduplicates permalinks, rejects mentions and lookalike domains", () => { + const data = { provider: "exa", results: [ + result("https://www.instagram.com/p/product1/", 'Areaware on Instagram: "New Cache Box"', { image: "https://scontent.cdninstagram.com/image.jpg", publishedDate: "2026-01-02" }), + result("https://www.instagram.com/areaware/p/product1/?utm_source=search", "Same post"), + result("https://instagram.com/p/product2/", "Areaware | New puzzle collection"), + result("https://instagram.com/shop/p/product3/", 'Areaware on Instagram: "A reseller mention"'), + result("https://instagram.com/p/product4/", 'Slowdown Studio on Instagram: "New @areaware puzzle"'), + result("https://instagram.com.evil.test/p/product5/", 'Areaware on Instagram: "New vase"'), + result("https://instagram.com/areaware/", "Areaware (@areaware)"), + ] }; + const { media, excluded, profile } = normalizeSearchResults("areaware", data); + assert.deepEqual(media.map((p) => p.id), ["product1", "product2"]); + assert.equal(excluded.length, 3); + assert.equal(profile.name, "Areaware"); + assert.equal(media[0].caption, "New Cache Box"); + assert.equal(media[0].timestamp, null); + assert.equal(media[0].like_count, null); + assert.equal(media[0].media_type, "UNKNOWN"); + assert.equal(media[0].discovery.indexed_published_date, "2026-01-02"); +}); + +test("reels remain videos; thumbnails cannot point to a local or unrelated host", () => { + const { media } = normalizeSearchResults("areaware", { results: [ + result("https://instagram.com/areaware/reel/reel1/", "New vase", { image: "http://127.0.0.1/private" }), + result("https://instagram.com/areaware/p/post1/", "New vase", { image: "https://unrelated.test/image" }), + ] }); + assert.equal(media[0].media_type, "VIDEO"); + assert.equal(media[0].thumbnail_url, null); + assert.equal(media[1].media_url, null); +}); + +test("keyless search uses the Instagram domain and reports API errors without fixtures", async (t) => { + t.mock.method(globalThis, "fetch", async (url, options) => { + assert.match(url, /^https:\/\/mcp\.exa\.ai\//); + assert.equal(options.headers.Authorization, undefined); + const request = JSON.parse(options.body); + assert.deepEqual(request.params.arguments.includeDomains, ["instagram.com"]); + return new Response(`event: message\ndata: ${JSON.stringify({ id: 1, result: { content: [{ type: "text", text: JSON.stringify({ results: [] }) }] } })}\n\n`, { headers: { "content-type": "text/event-stream" } }); + }); + assert.equal((await searchInstagram("areaware", 3)).provider, "exa"); + globalThis.fetch.mock.mockImplementation(async () => new Response("limited", { status: 429 })); + await assert.rejects(searchInstagram("areaware"), /HTTP 429; no fixture substituted/); +}); + +test("saved search evidence reaches the brief without fabricated counts, dates or Graph provenance", () => { + const dir = mkdtempSync(join(tmpdir(), "ig-search-test-")); + try { + const evidence = join(dir, "search.json"); + writeFileSync(evidence, JSON.stringify({ provider: "exa", results: [result("https://instagram.com/areaware/p/cache1/", 'Areaware on Instagram: "New Cache Box collection"')] })); + const run = (...args) => spawnSync(process.execPath, [join(import.meta.dirname, "ig-gun.mjs"), ...args], { encoding: "utf8", env: { ...process.env, IG_GUN_OUT: dir } }); + let r = run("scout", "areaware", "--search-results", evidence, "--no-media"); + assert.equal(r.status, 0, r.stderr); + r = run("brief", "areaware", "--product", "Cache Box"); + assert.equal(r.status, 0, r.stderr); + const output = join(dir, "areaware"); + assert.match(readFileSync(join(output, "brief.md"), "utf8"), /public-search indexed public search/); + assert.doesNotMatch(readFileSync(join(output, "data/README.md"), "utf8"), /official Graph API/); + assert.match(readFileSync(join(output, "data/signals.csv"), "utf8"), /engagement unknown/); + assert.equal(JSON.parse(readFileSync(join(output, "provenance.json"))).reference[0].file, null); + assert.notEqual(run("scout", "../outside", "--fixture").status, 0); + writeFileSync(evidence, JSON.stringify({ results: [] })); + assert.notEqual(run("scout", "areaware", "--search-results", evidence).status, 0); + assert.equal(JSON.parse(readFileSync(join(output, "api.json"))).source, "public-search"); + assert.equal(JSON.parse(readFileSync(join(output, "search.json"))).results.length, 1); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/help/relay/README.md b/help/relay/README.md new file mode 100644 index 0000000000..c0d734f9bf --- /dev/null +++ b/help/relay/README.md @@ -0,0 +1,80 @@ +# Personal Aesel relay + +`help.aesthetic.computer/api/aesel` runs the same ClaudeServer and AppServer +adapters as Aesel. The verified `ADMIN_SUB` alone can connect, using their AC +access token. Provider credentials stay on the server. Existing help routes +continue on port 3004; Caddy routes `/api/aesel/*` to loopback port 3006. + +From the terminal, with `ac login` already complete: + +```sh +aes --backend relay --model claude-sonnet-4-6 +# Once Codex is authenticated on the VPS: +AESEL_RELAY_PROVIDER=codex aes --backend relay --model gpt-6-astra +``` + +Work runs in a separate directory on the VPS, not the client's local checkout. +Tool approvals return to the terminal. Closing the client detaches without +cancelling the turn. `--resume ` reconnects; the relay replays +its saved events and only unresolved approvals. No filesystem synchronization +or phone integration is implicit in this backend. + +## Protocol + +Every private request needs `Authorization: Bearer `. + +- `POST /api/aesel/sessions`: `{provider:"claude"|"codex", model?, effort?, instructions?}`. +- `POST /api/aesel/sessions/:id/turn`: `{requestId:, text, images?:[{mimeType,data:}]}`. +- `GET /api/aesel/sessions/:id?after=`: events (up to 500), pending approvals, request statuses. +- `POST /api/aesel/sessions/:id/respond`: `{id, result}` matching an outstanding engine request. +- `POST /api/aesel/sessions/:id/interrupt`: `{}`. + +Submit each attempt with a stable request UUID. Retries with the same UUID +return its status without executing again. The full input, including image +bytes, is written before acknowledgement. Events are appended as they arrive; +a service restart marks unfinished attempts interrupted and preserves input. +A new request UUID is an explicit retry. State lives under +`/var/lib/aesel-relay/sessions`, private to the service account. Nothing deletes +saved drawings automatically. This does not save an unsent phone draft. + +## Host setup + +Use a dedicated `aesel` system account with home `/var/lib/aesel-relay`. +Install provider CLIs under `/opt/aesel-relay/runtime` (verified versions: +Claude Code 2.1.289 and Codex 0.160.0). The relay unit runs as that account, +with a read-only system and private temporary directory. + +Root-owned `/etc/aesel-relay.env` (0600) supplies `ADMIN_SUB`, `AUTH0_DOMAIN` +and `CLAUDE_CODE_OAUTH_TOKEN`. `claude setup-token` supplies Claude's credential; +do not put it in source, a client bundle, logs or chat. The existing help +subscription token can be reused. API-key environment variables are removed +before starting adapters, preventing implicit API billing fallback. + +Codex uses its own login in the service account's home: + +```sh +sudo -u aesel HOME=/var/lib/aesel-relay \ + /opt/aesel-relay/runtime/node_modules/.bin/codex login --device-auth +``` + +After login, set `CODEX_ENABLED=1` in the private environment file and restart +the relay. Do not copy credentials from a live prompt's process. Subscription +limits still apply. Claude's `costUSD` with `costBasis:"list"` is a list-price +usage estimate, not proof of an extra charge to the subscription. + +`help/relay/deploy.sh` installs the source and systemd unit. Set `HELP_SSH_KEY` +when deploying from an isolated checkout. Caddy configuration: + +```caddy +handle /api/aesel/* { + reverse_proxy 127.0.0.1:3006 +} +handle { + reverse_proxy localhost:3004 +} +``` + +Validate with `node --test help/relay/service.test.mjs` and the existing Aesel +provider adapter tests. Production checks must include unauthorized rejection +and a real authenticated model turn. Whistlegraph still uses the messages API; +it needs a client adapter for this session protocol before switching traffic. diff --git a/help/relay/aesel-relay.service b/help/relay/aesel-relay.service new file mode 100644 index 0000000000..a32fff9dad --- /dev/null +++ b/help/relay/aesel-relay.service @@ -0,0 +1,26 @@ +[Unit] +Description=Private Aesel provider relay +After=network-online.target +Wants=network-online.target +[Service] +User=aesel +Group=aesel +WorkingDirectory=/opt/aesel-relay/app +EnvironmentFile=/etc/aesel-relay.env +Environment=HOME=/var/lib/aesel-relay +Environment=PATH=/opt/aesel-relay/runtime/node_modules/.bin:/usr/local/bin:/usr/bin:/bin +Environment=AESEL_RELAY_STATE=/var/lib/aesel-relay/sessions +Environment=CLAUDE_BIN=/opt/aesel-relay/runtime/node_modules/.bin/claude +Environment=CODEX_BIN=/opt/aesel-relay/runtime/node_modules/.bin/codex +ExecStart=/usr/bin/node /opt/aesel-relay/app/help/relay/service.mjs +Restart=on-failure +RestartSec=3 +UMask=0077 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/aesel-relay +StateDirectory=aesel-relay +[Install] +WantedBy=multi-user.target diff --git a/help/relay/deploy.sh b/help/relay/deploy.sh new file mode 100755 index 0000000000..242789956d --- /dev/null +++ b/help/relay/deploy.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Deploy only the personal relay. Existing help/aa routes stay on port 3004. +set -euo pipefail +repo=$(cd "$(dirname "$0")/../.." && pwd) +host=${HELP_HOST:-root@help.aesthetic.computer} +key=${HELP_SSH_KEY:-$repo/aesthetic-computer-vault/home/.ssh/id_rsa} +ssh -i "$key" "$host" 'node --input-type=module' <<'JS' +const response=await fetch("http://127.0.0.1:3006/health").catch(()=>null); +if(response?.ok && (await response.json()).busy) throw Error("Aesel is working; wait for the turn before deploying"); +JS +ssh -i "$key" "$host" 'mkdir -p /opt/aesel-relay/app/help/relay /opt/aesel-relay/app/aesel' +rsync -az -e "ssh -i $key" "$repo/help/relay/" "$host:/opt/aesel-relay/app/help/relay/" +rsync -az -e "ssh -i $key" "$repo/aesel/src" "$repo/aesel/context" "$repo/aesel/package.json" "$host:/opt/aesel-relay/app/aesel/" +ssh -i "$key" "$host" 'cp /opt/aesel-relay/app/help/relay/aesel-relay.service /etc/systemd/system/ && systemctl daemon-reload && systemctl enable --now aesel-relay && systemctl restart aesel-relay && curl --retry 5 --retry-connrefused --retry-delay 1 --fail -s http://127.0.0.1:3006/health' diff --git a/help/relay/service.mjs b/help/relay/service.mjs new file mode 100644 index 0000000000..db18a585f2 --- /dev/null +++ b/help/relay/service.mjs @@ -0,0 +1,173 @@ +// Private, durable transport for Aesel's existing provider adapters. +import http from 'node:http'; +import {randomUUID, createHash} from 'node:crypto'; +import {mkdirSync, readFileSync, writeFileSync, appendFileSync, renameSync, existsSync} from 'node:fs'; +import {join} from 'node:path'; +import {pathToFileURL} from 'node:url'; +import {ClaudeServer} from '../../aesel/src/claude-server.mjs'; +import {AppServer} from '../../aesel/src/app-server.mjs'; + +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const fail = (status, message) => Object.assign(new Error(message), {status}); +const json = (res, status, data) => {res.writeHead(status, {'content-type':'application/json','cache-control':'no-store'});res.end(JSON.stringify(data));}; +function save(path, value) {writeFileSync(path+'.tmp', JSON.stringify(value), {mode:0o600});renameSync(path+'.tmp', path);} +async function body(req) { + let text = ''; + for await (const chunk of req) {text += chunk;if (Buffer.byteLength(text)>8*1024*1024) throw fail(413,'Request too large');} + try {return JSON.parse(text || '{}');} catch {throw fail(400,'Invalid JSON');} +} + +export function ownerAuth({adminSub, domain='aesthetic.us.auth0.com', fetch=globalThis.fetch}) { + if (!adminSub) throw Error('ADMIN_SUB is required'); + const cache = new Map(); + return async header => { + if (!/^Bearer \S+$/.test(header || '')) throw fail(401,'Sign in to Aesthetic Computer'); + const key=createHash('sha256').update(header).digest('hex'); + if ((cache.get(key)||0)>Date.now()) return; + const response=await fetch(`https://${domain}/userinfo`, {headers:{authorization:header},signal:AbortSignal.timeout(10000)}); + if (!response.ok) throw fail(401,'Session expired'); + const user=await response.json(); + if (user.sub!==adminSub || user.email_verified!==true) throw fail(403,'This relay is private'); + if (cache.size>=100) cache.clear(); + cache.set(key,Date.now()+60000); + }; +} + +export function createRelay({root, authorize, factory, maxActive=2}={}) { + if (!root || !authorize) throw Error('State directory and authorization are required'); + mkdirSync(root,{recursive:true,mode:0o700}); + const sessions=new Map(); + function persist(s) {save(join(s.dir,'session.json'),s.meta);} + function event(s,type,value) { + const entry={seq:++s.seq,at:new Date().toISOString(),type,value}; + appendFileSync(join(s.dir,'events.jsonl'),JSON.stringify(entry)+'\n',{mode:0o600}); + s.events.push(entry); + } + function load(id) { + if (!uuid.test(id)) throw fail(400,'Invalid session id'); + if (sessions.has(id)) return sessions.get(id); + const dir=join(root,id); + if (!existsSync(join(dir,'session.json'))) throw fail(404,'Session not found'); + const meta=JSON.parse(readFileSync(join(dir,'session.json'),'utf8')); + const events=existsSync(join(dir,'events.jsonl'))?readFileSync(join(dir,'events.jsonl'),'utf8').trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)):[]; + const s={dir,meta,events,seq:events.at(-1)?.seq||0,engine:null,pending:new Map()}; + sessions.set(id,s); + if (meta.busy) { + meta.busy=false; + for (const request of Object.values(meta.requests)) if(request.status==='running') request.status='interrupted'; + event(s,'notification',{method:'turn/completed',params:{turn:{id:meta.turnId,status:'interrupted',error:{message:'Relay restarted; your saved input is available to retry.'}}}}); + persist(s); + } + return s; + } + async function engine(s) { + clearTimeout(s.idleTimer); + if(s.engine && !s.engine.closed) return s.engine; + if(s.opening) return s.opening; + s.opening=(async()=>{ + const options={cwd:join(s.dir,'workspace'),resumeThreadId:s.meta.engineThreadId||'',model:s.meta.model,effort:s.meta.effort, + developerInstructions:s.meta.instructions,tools:true}; + // Only the service's subscription credentials reach the child. Never + // forward HTTP credentials, client environment, commands or local paths. + const e=factory?factory(s.meta.provider,options):s.meta.provider==='claude' + ?new ClaudeServer({...options,command:process.env.CLAUDE_BIN||'claude'}) + :new AppServer({...options,command:process.env.CODEX_BIN||'codex'}); + s.engine=e; + e.on('notification',value=>{ + // CLI turn counters restart with each process. The accepted request UUID + // is stable across idle shutdown, reconnection and provider resume. + value=structuredClone(value); + if(value.params?.turn) value.params.turn.id=s.meta.currentRequest; + if(value.params?.turnId) value.params.turnId=s.meta.currentRequest; + if(value.params?.threadId) value.params.threadId=s.meta.id; + if(value.method==='turn/started') s.meta.turnId=value.params?.turn?.id; + if(value.method==='turn/completed') { + s.meta.busy=false; + const request=s.meta.requests[s.meta.currentRequest]; + if(request) request.status=value.params?.turn?.status||'failed'; + s.pending.clear(); + s.idleTimer=setTimeout(()=>{if(!s.meta.busy){e.close();s.engine=null;}},120000);s.idleTimer.unref(); + } + event(s,'notification',value);persist(s); + }); + e.on('request',value=>{s.pending.set(String(value.id),value);event(s,'request',value);}); + e.on('fatal',()=>{ + s.meta.busy=false; + const request=s.meta.requests[s.meta.currentRequest];if(request)request.status='failed'; + s.pending.clear();event(s,'fatal',{message:'Provider connection failed; saved input can be retried.'});persist(s); + }); + const result=await e.connect(); + s.meta.engineThreadId=e.threadId||result.thread.id;persist(s); + return e; + })().catch(error=>{s.engine?.close();s.engine=null;throw error;}).finally(()=>{s.opening=null;}); + return s.opening; + } + const server=http.createServer(async(req,res)=>{ + try { + const url=new URL(req.url,'http://localhost'); + if(url.pathname==='/health' && req.method==='GET') return json(res,200,{ok:true,service:'aesel-relay',version:1,busy:[...sessions.values()].filter(s=>s.meta.busy).length}); + await authorize(req.headers.authorization); + if(url.pathname==='/api/aesel/sessions' && req.method==='POST') { + const input=await body(req); + if(!['claude','codex'].includes(input.provider))throw fail(400,'Choose claude or codex'); + if(input.provider==='codex' && !factory && process.env.CODEX_ENABLED!=='1') throw fail(503,'Codex needs a server-side login'); + for(const key of ['model','effort','instructions'])if(input[key]!=null && (typeof input[key]!=='string'||input[key].length>(key==='instructions'?64000:100)))throw fail(400,`Invalid ${key}`); + const id=randomUUID(),dir=join(root,id); + mkdirSync(join(dir,'workspace'),{recursive:true,mode:0o700}); + const meta={id,provider:input.provider,model:input.model||undefined,effort:input.effort||'',instructions:input.instructions||'',requests:{},busy:false,created:new Date().toISOString()}; + save(join(dir,'session.json'),meta); + return json(res,201,{thread:{id},provider:meta.provider}); + } + const match=url.pathname.match(/^\/api\/aesel\/sessions\/([^/]+)(?:\/(turn|respond|interrupt))?$/); + if(!match)throw fail(404,'Not found'); + const s=load(match[1]),action=match[2]; + if(req.method==='GET' && !action) { + const after=Number(url.searchParams.get('after')||0); + if(!Number.isSafeInteger(after)||after<0)throw fail(400,'Invalid event cursor'); + return json(res,200,{thread:{id:s.meta.id},provider:s.meta.provider,busy:s.meta.busy,events:s.events.filter(e=>e.seq>after).slice(0,500),pending:[...s.pending.values()],requests:s.meta.requests}); + } + if(req.method!=='POST')throw fail(405,'Method not allowed'); + const input=await body(req); + if(action==='turn') { + if(!uuid.test(input.requestId||''))throw fail(400,'requestId must be a UUID'); + if(Object.hasOwn(s.meta.requests,input.requestId))return json(res,200,s.meta.requests[input.requestId]); + if(typeof input.text!=='string'||!input.text.trim()||input.text.length>500000)throw fail(400,'Invalid turn text'); + const images=input.images||[]; + if(!Array.isArray(images)||images.length>8||images.some(i=>!i||!['image/png','image/jpeg','image/webp','image/gif'].includes(i.mimeType)||typeof i.data!=='string'||!/^[A-Za-z0-9+/]*={0,2}$/.test(i.data)))throw fail(400,'Invalid images'); + if(s.meta.busy)throw fail(409,'This session is already working'); + if([...sessions.values()].filter(v=>v.meta.busy).length>=maxActive)throw fail(429,'Relay is busy; retry shortly'); + // Save the complete request before spawning or acknowledging it. Losing + // a network connection never loses an accepted drawing or repeats a turn. + save(join(s.dir,input.requestId+'.json'),input); + s.meta.busy=true;s.meta.currentRequest=input.requestId; + s.meta.requests[input.requestId]={requestId:input.requestId,status:'running'};persist(s); + json(res,202,s.meta.requests[input.requestId]); + void (async()=>{ + try {const e=await engine(s);await e.startTurn(input.text,{images});} + catch {s.meta.busy=false;s.meta.requests[input.requestId].status='failed';event(s,'fatal',{message:'Could not start provider; input was saved. Check server authentication.'});persist(s);} + })(); + return; + } + if(action==='respond') { + if(!s.pending.has(String(input.id)))throw fail(409,'Approval is no longer pending'); + const pending=s.pending.get(String(input.id)); + if(!input.result || typeof input.result!=='object' || Array.isArray(input.result))throw fail(400,'Invalid response'); + if(pending.method.endsWith('/requestApproval')&&!['accept','acceptForSession','decline','cancel'].includes(input.result.decision))throw fail(400,'Invalid approval decision'); + s.engine.respond(input.id,input.result);s.pending.delete(String(input.id)); + event(s,'approval',{id:input.id,decision:input.result.decision});return json(res,200,{ok:true}); + } + if(action==='interrupt') {await s.engine?.interrupt();return json(res,200,{ok:true});} + throw fail(404,'Not found'); + } catch(error) {if(!res.headersSent)json(res,error.status||500,{error:error.status?error.message:'Relay request failed'});else res.end();} + }); + server.on('close',()=>{for(const s of sessions.values()){clearTimeout(s.idleTimer);s.engine?.close();}}); + return server; +} + +if(process.argv[1] && import.meta.url===pathToFileURL(process.argv[1]).href) { + // A private subscription relay must never silently fall through to paid API keys. + for(const key of ['ANTHROPIC_API_KEY','ANTHROPIC_AUTH_TOKEN','OPENAI_API_KEY','OPENROUTER_API_KEY'])delete process.env[key]; + const server=createRelay({root:process.env.AESEL_RELAY_STATE||'/var/lib/aesel-relay',authorize:ownerAuth({adminSub:process.env.ADMIN_SUB,domain:process.env.AUTH0_DOMAIN})}); + server.listen(Number(process.env.AESEL_RELAY_PORT||3006),'127.0.0.1',()=>console.log('Aesel relay listening on loopback')); + for(const signal of ['SIGTERM','SIGINT'])process.on(signal,()=>{server.close();setTimeout(()=>process.exit(0),1000).unref();}); +} diff --git a/help/relay/service.test.mjs b/help/relay/service.test.mjs new file mode 100644 index 0000000000..1ad039386f --- /dev/null +++ b/help/relay/service.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {EventEmitter, once} from 'node:events'; +import {mkdtempSync,readFileSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {randomUUID} from 'node:crypto'; +import {createRelay,ownerAuth} from './service.mjs'; +import {RemoteServer} from '../../aesel/src/remote-server.mjs'; +class Engine extends EventEmitter { + async connect(){this.threadId=randomUUID();return {thread:{id:this.threadId}};} + async startTurn(text){this.starts=(this.starts||0)+1;this.text=text;this.emit('notification',{method:'turn/started',params:{turn:{id:'turn-1'}}});this.emit('request',{id:'approval-1',method:'item/commandExecution/requestApproval',params:{command:'edit piece'}});} + respond(id,result){this.decision=result.decision;this.emit('notification',{method:'item/agentMessage/delta',params:{delta:'Saved.'}});this.emit('notification',{method:'turn/completed',params:{turn:{id:'turn-1',status:'completed'}}});} + close(){this.closed=true;} +} +async function setup(t){ + const root=mkdtempSync(join(tmpdir(),'aesel-relay-'));let engine; + const authorize=async header=>{if(header!=='Bearer owner')throw Object.assign(Error('Private'),{status:403});}; + const start=async()=>{const server=createRelay({root,authorize,factory:()=>engine=new Engine()});server.listen(0,'127.0.0.1');await once(server,'listening');return server;}; + let server=await start(); + const url=()=>`http://127.0.0.1:${server.address().port}`; + const call=async(path,data,token='owner')=>{const r=await fetch(url()+path,{method:data?'POST':'GET',headers:{authorization:`Bearer ${token}`,'content-type':'application/json'},...(data?{body:JSON.stringify(data)}:{})});return {status:r.status,...await r.json()};}; + t.after(async()=>{server.closeAllConnections();await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});}); + return {root,url,call,engine:()=>engine,restart:async()=>{server.closeAllConnections();await new Promise(r=>server.close(r));server=await start();}}; +} +const base='/api/aesel/sessions'; +test('only the verified owner can enter, with no token cached in plaintext',async()=>{ + let calls=0;const auth=ownerAuth({adminSub:'owner',fetch:async()=>{calls++;return {ok:true,json:async()=>({sub:'owner',email_verified:true})};}}); + await assert.rejects(auth(),{status:401});await auth('Bearer ok');await auth('Bearer ok');assert.equal(calls,1); + const denied=ownerAuth({adminSub:'owner',fetch:async()=>({ok:true,json:async()=>({sub:'someone-else',email_verified:true})})});await assert.rejects(denied('Bearer bad'),{status:403}); +}); +test('saved drawing, idempotent submission, approvals and restart recovery',async t=>{ + const f=await setup(t);assert.equal((await f.call(base,{provider:'claude'},'other')).status,403); + const {thread}=await f.call(base,{provider:'claude'});const requestId=randomUUID(); + const input={requestId,text:'Draw this',images:[{mimeType:'image/png',data:'aGVsbG8='}]}; + assert.equal((await f.call(`${base}/${thread.id}/turn`,input)).status,'running'); + await new Promise(r=>setTimeout(r,10)); + await f.call(`${base}/${thread.id}/turn`,input);assert.equal(f.engine().starts,1); + assert.deepEqual(JSON.parse(readFileSync(join(f.root,thread.id,requestId+'.json'))),input); + const events=await f.call(`${base}/${thread.id}`);assert.equal(events.pending.length,1); + await f.call(`${base}/${thread.id}/respond`,{id:'approval-1',result:{decision:'accept'}}); + assert.equal(f.engine().decision,'accept');assert.equal((await f.call(`${base}/${thread.id}`)).busy,false); + await f.restart();const restored=await f.call(`${base}/${thread.id}`);assert.equal(restored.requests[requestId].status,'completed');assert.ok(restored.events.some(e=>e.value.method==='turn/completed')); +}); +test('an interrupted service retains input and never repeats the provider turn',async t=>{ + const f=await setup(t);const {thread}=await f.call(base,{provider:'claude'});const input={requestId:randomUUID(),text:'Keep me'}; + await f.call(`${base}/${thread.id}/turn`,input);await new Promise(r=>setTimeout(r,10));await f.restart(); + const restored=await f.call(`${base}/${thread.id}`);assert.equal(restored.busy,false);assert.equal(restored.requests[input.requestId].status,'interrupted'); + assert.equal((await f.call(`${base}/${thread.id}/turn`,input)).status,'interrupted'); +}); +test('remote Aesel adapter carries streaming and approvals across HTTP',async t=>{ + const f=await setup(t);const remote=new RemoteServer({url:f.url(),token:async()=> 'owner',pollMs:5});t.after(()=>remote.close()); + const result=await remote.connect();assert.ok(result.thread.id); + remote.on('request',request=>remote.respond(request.id,{decision:'accept'})); + const done=new Promise(resolve=>remote.on('notification',v=>{if(v.method==='turn/completed')resolve(v.params.turn.status);})); + await remote.startTurn('Make something');assert.equal(await done,'completed');assert.equal(f.engine().decision,'accept'); +}); +test('lost acknowledgement retries the same request without starting twice',async t=>{ + const f=await setup(t);let lost=false; + const remote=new RemoteServer({url:f.url(),token:async()=> 'owner',pollMs:5,fetch:async(url,options)=>{ + const response=await fetch(url,options); + if(url.endsWith('/turn')&&!lost){lost=true;await response.text();throw Error('Connection lost after acceptance');} + return response; + }});t.after(()=>remote.close());await remote.connect(); + const result=await remote.startTurn('Exactly once');assert.equal(result.turn.status,'inProgress');assert.equal(f.engine().starts,1); + const state=await f.call(`${base}/${remote.threadId}`);assert.equal(state.events[0].value.params.turn.id,result.turn.id); +}); diff --git a/slab/README.md b/slab/README.md index b7c5afe78f..90ee0da281 100644 --- a/slab/README.md +++ b/slab/README.md @@ -62,7 +62,7 @@ Active work is tracked as marker files in two directories: The daemon treats the total file count as "active work". When the Stop hook runs with zero remaining markers and the lid is closed, ambient -stops, the all-done chime plays, and the Mac sleeps. +stops. Unless manually held awake, the sleep chime plays and the Mac sleeps. ## What it does @@ -76,8 +76,8 @@ stops, the all-done chime plays, and the Mac sleeps. | Subagent finishes (Task tool) | single ping | | Claude Stop, **other active work remaining** | N ascending beeps (C6 D6 E6 G6 A6 C7 D7 E7) | | Claude Stop, all work done, lid open | "all-done" chime | -| Claude Stop, all work done, lid closed | TTS "i'm tired" with cosine fade-out tail → `pmset sleepnow` | -| User submits new prompt | touches active-prompts marker, sets `disablesleep=1` | +| Claude Stop, all work done, lid closed | Sleep announcement and automatic sleep, unless manually held awake | +| User submits new prompt | touches active-prompts marker, enables temporary sleep protection | ## Install @@ -165,14 +165,21 @@ prompts are the two things no check here can prove. ## Usage ```sh -claude-sleep awake # disable all sleep (stay awake with lid closed) -claude-sleep auto # restore normal sleep behavior -claude-sleep now # sleep the Mac immediately -claude-sleep status # show SleepDisabled state +claude-sleep awake # stay awake until explicitly released +claude-sleep auto # release the hold and allow normal sleep +claude-sleep now # release the hold and sleep immediately +claude-sleep status # show SleepDisabled and the manual StayAwake preference ``` Before using lid-closed ambient mode: `claude-sleep awake`. The first time the mic listener runs you'll get a macOS microphone permission prompt. +The menu's **Stay awake (lid closed)** choice is saved in +`$SLAB_HOME/state/stay-awake` and reapplied when the menubar starts or the Mac +wakes. Automatic completion and delayed sleep respect this choice. Hooks use +`claude-sleep work` for temporary protection and `claude-sleep idle` for automatic +sleep; neither changes the manual preference. `auto` and **Sleep now** explicitly +release it. Transitions are recorded in `$SLAB_HOME/logs/sleep-control.log`. + ## Uninstall ```sh diff --git a/slab/bin/claude-prompt-log.sh b/slab/bin/claude-prompt-log.sh index 5b1c53053c..154f0302ff 100755 --- a/slab/bin/claude-prompt-log.sh +++ b/slab/bin/claude-prompt-log.sh @@ -22,7 +22,7 @@ input=$(cat) rm -f /tmp/slab-ambient-paused # keep the machine awake while this new prompt runs -"$SLAB_BIN/claude-sleep" awake >/dev/null 2>&1 & +"$SLAB_BIN/claude-sleep" work >/dev/null 2>&1 & if [[ -n "$input" ]]; then mkdir -p "$ACTIVE_DIR" "$AWAITING_DIR" diff --git a/slab/bin/claude-sleep b/slab/bin/claude-sleep index 21dda851cb..715959e73c 100755 --- a/slab/bin/claude-sleep +++ b/slab/bin/claude-sleep @@ -1,34 +1,98 @@ #!/bin/bash # Unified sleep control for the slab workflow. # -# claude-sleep awake # disable all sleep (stay awake with lid closed) -# claude-sleep auto # restore normal sleep behavior -# claude-sleep now # sleep the Mac immediately -# claude-sleep status # show SleepDisabled state +# claude-sleep awake # stay awake until explicitly released +# claude-sleep auto # release the manual hold and allow normal sleep +# claude-sleep now # release the manual hold and sleep immediately +# claude-sleep work # temporary keep-awake for an agent turn +# claude-sleep idle # automatic sleep, unless manually held awake +# claude-sleep restore # reapply a saved manual hold at login/wake +# claude-sleep status # show actual sleep state and manual preference # # Requires passwordless sudo for /usr/bin/pmset. See slab install.sh. -set -u -PMSET=/usr/bin/pmset +set -eu +PMSET=${SLAB_PMSET:-/usr/bin/pmset} +SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} +HOLD="$SLAB_HOME/state/stay-awake" -case "${1:-status}" in +# Serialize the preference and pmset together: a finishing turn must not +# clear SleepDisabled after a concurrent manual enable has succeeded. +mkdir -p "$SLAB_HOME/state" "$SLAB_HOME/logs" +if [[ "${1:-}" != "--locked" ]]; then + /usr/bin/lockf -k -t 10 "$SLAB_HOME/state/sleep-control.lock" "$0" --locked "$@" 2>&1 + exit $? +fi +shift + +log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$SLAB_HOME/logs/sleep-control.log"; } +set_sleep() { + local detail + if detail=$(sudo -n "$PMSET" -a disablesleep "$1" 2>&1); then + return 0 + fi + # stdout is consumed by the menubar; do not silently discard failures. + echo "Could not change sleep protection: $detail" + log "${command}: failed to set SleepDisabled=$1" + return 1 +} +sleep_now() { + sudo -n "$PMSET" sleepnow 2>&1 \ + || osascript -e 'tell application "System Events" to sleep' 2>&1 +} + +command=${1:-status} +case "$command" in awake) - sudo -n "$PMSET" -a disablesleep 1 2>/dev/null && echo "awake (SleepDisabled=1)" + was_held=0 + [[ ! -f "$HOLD" ]] || was_held=1 + touch "$HOLD" + if ! set_sleep 1; then + [[ "$was_held" == 1 ]] || rm -f "$HOLD" + exit 1 + fi + log "awake: manual hold enabled" + echo "awake (SleepDisabled=1, StayAwake=1)" ;; auto) - sudo -n "$PMSET" -a disablesleep 0 2>/dev/null && echo "auto (SleepDisabled=0)" + set_sleep 0 + rm -f "$HOLD" + log "auto: manual hold released" + echo "auto (SleepDisabled=0, StayAwake=0)" ;; now) - sudo -n "$PMSET" -a disablesleep 0 2>/dev/null - sudo -n "$PMSET" sleepnow 2>/dev/null \ - || osascript -e 'tell application "System Events" to sleep' 2>/dev/null + set_sleep 0 + rm -f "$HOLD" + log "now: explicit sleep, manual hold released" + sleep_now + ;; + work) + set_sleep 1 + log "work: temporary sleep protection enabled" + ;; + idle) + if [[ -f "$HOLD" ]]; then + log "idle: sleep skipped, manual hold enabled" + echo "staying awake (manual hold)" + exit 0 + fi + set_sleep 0 + log "idle: automatic sleep" + sleep_now + ;; + restore) + if [[ -f "$HOLD" ]]; then + set_sleep 1 + log "restore: manual hold reapplied" + fi ;; status) state=$("$PMSET" -g | awk '/SleepDisabled/{print $2; exit}') echo "SleepDisabled=${state:-0}" + if [[ -f "$HOLD" ]]; then echo "StayAwake=1"; else echo "StayAwake=0"; fi ;; *) - echo "usage: $0 [awake|auto|now|status]" >&2 + echo "usage: $0 [awake|auto|now|work|idle|restore|status]" >&2 exit 1 ;; esac diff --git a/slab/bin/claude-sleep-schedule.sh b/slab/bin/claude-sleep-schedule.sh index f3ad6ced24..4da3b878e2 100755 --- a/slab/bin/claude-sleep-schedule.sh +++ b/slab/bin/claude-sleep-schedule.sh @@ -15,7 +15,7 @@ SLEEP_TONE="$SLAB_HOME/sounds/sleep-tone.wav" sleep "$DELAY" lid=$(ioreg -r -k AppleClamshellState -d 4 | awk '/AppleClamshellState/{print $NF; exit}') -if [[ "$lid" == "Yes" ]]; then +if [[ "$lid" == "Yes" && ! -f "$SLAB_HOME/state/stay-awake" ]]; then py="$SLAB_HOME/venv/bin/python3" helper="$SLAB_BIN/jeffrey-say.py" if [[ -x "$py" && -f "$helper" ]]; then @@ -23,5 +23,5 @@ if [[ "$lid" == "Yes" ]]; then else "$SLAB_BIN/slab-afplay" "$SLEEP_TONE" fi - "$SLAB_BIN/claude-sleep" now + "$SLAB_BIN/claude-sleep" idle fi diff --git a/slab/bin/claude-stop.sh b/slab/bin/claude-stop.sh index 471983bd94..f5927e5731 100755 --- a/slab/bin/claude-stop.sh +++ b/slab/bin/claude-stop.sh @@ -12,8 +12,8 @@ # bar for the live thread) and just flip its state to awaiting. # others > 0 → N distinct ascending pentatonic beeps (capped at 8). # others = 0 → "all done" chime (lid open) OR TTS "i'm tired" with fade-out -# tail → `pmset sleepnow` (lid closed: stops ambient first, so -# the transition to sleep is a gentle dissolve instead of a cut). +# tail → automatic sleep (lid closed, unless manually held awake; +# stops ambient first so the transition is a gentle dissolve). # A Claude that Aesel is driving is that session's engine, not a rock of its # own; Aesel keeps the marker, so this hook has nothing to say. (EASEL_ is the # name an Aesel from before the rename exports.) @@ -114,13 +114,14 @@ if [[ -e "$MUTE_FLAG" ]]; then # daemon-restarted pad doesn't sneak back on while muted. stop_ambient if (( others == 0 )) && [[ "$lid" == "Yes" ]]; then - "$SLAB_BIN/claude-sleep" now + "$SLAB_BIN/claude-sleep" idle fi elif (( others == 0 )); then if [[ "$lid" == "Yes" ]]; then stop_ambient - tired_stinger - "$SLAB_BIN/claude-sleep" now + # A manual stay-awake choice also suppresses the sleep announcement. + [[ -f "$SLAB_HOME/state/stay-awake" ]] || tired_stinger + "$SLAB_BIN/claude-sleep" idle else # Lid open + everything settled → Jeffrey "i'm all done". done_stinger diff --git a/slab/bin/slab-menubar.py b/slab/bin/slab-menubar.py index aee7fc7d8c..6708426619 100755 --- a/slab/bin/slab-menubar.py +++ b/slab/bin/slab-menubar.py @@ -26,6 +26,7 @@ SLAB_BIN = Path(os.environ.get("SLAB_BIN", os.path.expanduser("~/.local/bin"))) ACTIVE_DIR = SLAB_HOME / "state" / "active-prompts" SUBAGENT_DIR = SLAB_HOME / "state" / "active-subagents" +STAY_AWAKE_FLAG = SLAB_HOME / "state" / "stay-awake" AMBIENT_FLAG = Path("/tmp/slab-ambient-active") LID_LOG = SLAB_HOME / "logs" / "lidalive.log" DAEMON_PLIST = Path.home() / "Library/LaunchAgents/computer.slab.daemon.plist" @@ -211,7 +212,7 @@ class SlabApp(rumps.App): self.status_item.title = f"Status: {status}" self.prompts_item.title = f"Prompts in flight: {prompts}" self.subs_item.title = f"Subagents in flight: {subs}" - self.awake_item.state = 1 if sd else 0 + self.awake_item.state = 1 if STAY_AWAKE_FLAG.exists() else 0 self.refresh_tailnet() self.refresh_mail() @@ -263,7 +264,7 @@ class SlabApp(rumps.App): rumps.notification("slab", "Mail sync", "No sync log yet.") def toggle_awake(self, sender): - cmd = "auto" if sender.state else "awake" + cmd = "auto" if STAY_AWAKE_FLAG.exists() else "awake" subprocess.run([str(SLAB_BIN / "claude-sleep"), cmd], check=False) def sleep_now(self, _): diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index 8977c4d828..851ef7c656 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -252,6 +252,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // install or has answered this before. FirstRun.offerIfNeeded() LaunchPing.send() + restoreStayAwake() + NSWorkspace.shared.notificationCenter.addObserver( + self, selector: #selector(restoreStayAwake(_:)), + name: NSWorkspace.didWakeNotification, object: nil + ) DistributedNotificationCenter.default().addObserver( self, @@ -1723,6 +1728,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // MARK: - Deskflow KVM + @objc func openDisplayLayout() { DisplayLayoutWindow.show() } + /// Read the launchd agent label from the untracked deskflow config. /// Machine role/label/agent live there, never in tracked code. private func deskflowAgent() -> String? { @@ -1946,9 +1953,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } @objc func toggleStayAwake() { - let arg = state.sleepDisabled ? "auto" : "awake" - ShellRunner.runAsync(Paths.claudeSleep, args: [arg]) { [weak self] in - DispatchQueue.main.async { self?.refresh() } + let held = FileManager.default.fileExists(atPath: Paths.stayAwakeFlag) + runSleepControl(held ? "auto" : "awake") + } + + @objc private func restoreStayAwake(_ notification: Notification? = nil) { + guard FileManager.default.fileExists(atPath: Paths.stayAwakeFlag) else { return } + runSleepControl("restore") + } + + private func runSleepControl(_ command: String) { + DispatchQueue.global(qos: .utility).async { [weak self] in + let result = ShellRunner.run(Paths.claudeSleep, args: [command]) + DispatchQueue.main.async { + guard let self = self else { return } + self.refresh() + if result.status != 0 { + let alert = NSAlert() + alert.messageText = "Could not change sleep protection" + let detail = result.output.trimmingCharacters(in: .whitespacesAndNewlines) + alert.informativeText = detail.isEmpty + ? "Check Slab's sleep-control permission and try again." : detail + alert.alertStyle = .warning + alert.runModal() + } + } } } @@ -1995,7 +2024,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } @objc func sleepNow() { - ShellRunner.runAsync(Paths.claudeSleep, args: ["now"]) + runSleepControl("now") } /// Kick off the currently-selected screen saver now (the Slab Status diff --git a/slab/menubar-swift/Sources/SlabMenubar/DeskflowSpatialNav.swift b/slab/menubar-swift/Sources/SlabMenubar/DeskflowSpatialNav.swift index 106cca6f0b..aa7a99f23c 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/DeskflowSpatialNav.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/DeskflowSpatialNav.swift @@ -6,15 +6,25 @@ import Foundation /// controller is the only host allowed to synthesize edge motion. enum DeskflowSpatialNav { private struct Geometry { - var links: [String: [WindowNav.Direction: String]] + var links: [String: [(WindowNav.Direction, String)]] func positions(from source: String) -> [String: (x: Int, y: Int)] { + if let obj = DeskflowSpatialNav.json("\(Paths.home)/.config/slab/displays/routes.json"), + let screens = obj["screens"] as? [[String: Any]] { + var centers: [String: (x: Int, y: Int)] = [:] + for s in screens { + guard let name = s["screenName"] as? String, let x = s["x"] as? Int, + let y = s["y"] as? Int, let w = s["width"] as? Int, let h = s["height"] as? Int else { continue } + centers[name] = (x + w / 2, y + h / 2) + } + if let origin = centers[source] { return centers.mapValues { ($0.x - origin.x, $0.y - origin.y) } } + } var result: [String: (x: Int, y: Int)] = [source: (0, 0)] var queue = [source] while !queue.isEmpty { let here = queue.removeFirst() guard let origin = result[here] else { continue } - for (dir, next) in links[here] ?? [:] where result[next] == nil { + for (dir, next) in links[here] ?? [] where result[next] == nil { let step = dir.step result[next] = (origin.x + step.x, origin.y + step.y) queue.append(next) @@ -29,7 +39,7 @@ enum DeskflowSpatialNav { while !queue.isEmpty { let (here, path) = queue.removeFirst() if here == target { return path } - for (dir, next) in links[here] ?? [:] where seen.insert(next).inserted { + for (dir, next) in links[here] ?? [] where seen.insert(next).inserted { queue.append((next, path + [dir])) } } @@ -70,7 +80,7 @@ enum DeskflowSpatialNav { // Queue the destination immediately too, with only enough delay for // Deskflow to traverse the actual intermediate edges. This removes // the old conservative sleep from every cross-host arrow. - guard routeOnActiveController(path) else { return } + guard routeOnActiveController(path, target: target) else { return } let accepted = post(ip: targetLedger.ip, endpoint: "/navigate", body: [ "direction": (path.last ?? direction).rawValue, "alignment": Double(alignment), @@ -108,6 +118,10 @@ enum DeskflowSpatialNav { NSLog("slab fleet nav: refused route on non-controller role %@", localRole() ?? "unknown") return false } + if let target = body["target"] as? String, routeKey(target) != nil { + DispatchQueue.main.async { directRoute(target) } + return true + } guard let values = body["path"] as? [Any] else { NSLog("slab fleet nav: route payload has no path") return false @@ -122,14 +136,32 @@ enum DeskflowSpatialNav { return true } - private static func routeOnActiveController(_ path: [WindowNav.Direction]) -> Bool { + private static func routeKey(_ target: String) -> Int? { + guard let keys = json("\(Paths.home)/.config/slab/displays/routes.json")?["keys"] as? [String: Int], + let key = keys[target], [105, 107, 113, 106, 64, 79, 80, 90].contains(key) else { return nil } + return key + } + + private static func directRoute(_ target: String) { + guard let key = routeKey(target) else { return } + for down in [true, false] { + let event = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(key), keyDown: down) + event?.flags = [.maskControl, .maskAlternate, .maskShift] + event?.post(tap: .cghidEventTap) + } + } + + private static func routeOnActiveController(_ path: [WindowNav.Direction], target: String) -> Bool { if localRole() == "server" { - DispatchQueue.main.async { WindowNav.routeDeskflow(path) } + DispatchQueue.main.async { + if routeKey(target) != nil { directRoute(target) } + else { WindowNav.routeDeskflow(path) } + } return true } guard let ip = activeControllerAddress() else { return false } return post(ip: ip, endpoint: "/deskflow-route", - body: ["path": path.map(\.rawValue)]) + body: ["path": path.map(\.rawValue), "target": target]) } private static func targetScreen(from source: String, direction: WindowNav.Direction, @@ -159,7 +191,7 @@ enum DeskflowSpatialNav { encoding: .utf8) else { return nil } var inLinks = false var current = "" - var links: [String: [WindowNav.Direction: String]] = [:] + var links: [String: [(WindowNav.Direction, String)]] = [:] for raw in text.split(separator: "\n", omittingEmptySubsequences: false) { let line = raw.trimmingCharacters(in: .whitespaces) if line == "section: links" { inLinks = true; continue } @@ -167,15 +199,15 @@ enum DeskflowSpatialNav { guard inLinks, !line.isEmpty else { continue } if line.hasSuffix(":"), !line.contains(" = ") { current = String(line.dropLast()) - links[current, default: [:]] = links[current, default: [:]] + links[current, default: []] = links[current, default: []] continue } let parts = line.split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) } guard parts.count == 2, !current.isEmpty, - let dir = WindowNav.Direction(rawValue: parts[0]) else { continue } - links[current, default: [:]][dir] = parts[1] + let dir = WindowNav.Direction(rawValue: String(parts[0].prefix(while: { $0 != "(" }))) else { continue } + links[current, default: []].append((dir, String(parts[1].prefix(while: { $0 != "(" })))) } return links.isEmpty ? nil : Geometry(links: links) } diff --git a/slab/menubar-swift/Sources/SlabMenubar/DisplayLayoutWindow.swift b/slab/menubar-swift/Sources/SlabMenubar/DisplayLayoutWindow.swift new file mode 100644 index 0000000000..6a4fce366b --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/DisplayLayoutWindow.swift @@ -0,0 +1,359 @@ +import AppKit + +/// On-demand seat editor. Network probes and configuration transactions stay off AppKit's thread. +final class DisplayLayoutWindow: NSObject, NSWindowDelegate, NSTextFieldDelegate { + private static var shared: DisplayLayoutWindow? + private let window: NSWindow + private let canvas = DisplaySeatCanvas() + private let status = NSTextField(labelWithString: "Detecting displays…") + private let selection = NSTextField(labelWithString: "") + private var fields: [NSTextField] = [] + private var buttons: [NSButton] = [] + private var applyButton: NSButton! + private var restoreButton: NSButton! + private var state: [String: Any] = [:] + private var original: [[String: Any]] = [] + private var busy = false + private var dirty = false + + static func show() { + let controller = shared ?? DisplayLayoutWindow() + shared = controller + NSApp.activate(ignoringOtherApps: true) + controller.window.makeKeyAndOrderFront(nil) + } + + override init() { + window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 650), + styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false) + super.init() + window.title = "Desktop Layout" + window.minSize = NSSize(width: 760, height: 520) + window.isReleasedWhenClosed = false + window.delegate = self + window.setFrameAutosaveName("SlabDesktopLayout") + build() + window.center() + load() + } + + private func build() { + let content = NSView() + window.contentView = content + let stack = NSStackView() + stack.orientation = .vertical + stack.spacing = 14 + stack.edgeInsets = NSEdgeInsets(top: 18, left: 20, bottom: 18, right: 20) + stack.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: content.leadingAnchor), stack.trailingAnchor.constraint(equalTo: content.trailingAnchor), + stack.topAnchor.constraint(equalTo: content.topAnchor), stack.bottomAnchor.constraint(equalTo: content.bottomAnchor), + ]) + func button(_ title: String, _ action: Selector) -> NSButton { + let b = NSButton(title: title, target: self, action: action) + b.bezelStyle = .rounded; buttons.append(b); return b + } + let bar = NSStackView() + bar.spacing = 8 + bar.addArrangedSubview(button("Identify", #selector(identify))) + bar.addArrangedSubview(button("Refresh", #selector(refresh))) + bar.addArrangedSubview(button("Reset Draft", #selector(reset))) + let spacer = NSView(); spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + bar.addArrangedSubview(spacer) + restoreButton = button("Restore Previous", #selector(restore)) + bar.addArrangedSubview(restoreButton) + applyButton = button("Apply", #selector(apply)) + bar.addArrangedSubview(applyButton) + stack.addArrangedSubview(bar) + canvas.translatesAutoresizingMaskIntoConstraints = false + canvas.setContentHuggingPriority(.defaultLow, for: .vertical) + canvas.onChange = { [weak self] in self?.dirty = true; self?.update() } + canvas.onSelect = { [weak self] in self?.update() } + stack.addArrangedSubview(canvas) + let inspector = NSStackView() + inspector.spacing = 8 + selection.font = .systemFont(ofSize: 14, weight: .semibold) + selection.setContentHuggingPriority(.defaultLow, for: .horizontal) + inspector.addArrangedSubview(selection) + for key in ["X", "Y", "Width", "Height"] { + inspector.addArrangedSubview(NSTextField(labelWithString: key)) + let field = NSTextField(string: "") + field.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) + field.alignment = .right; field.delegate = self + field.widthAnchor.constraint(equalToConstant: 60).isActive = true + field.setAccessibilityLabel("Monitor \(key)") + fields.append(field); inspector.addArrangedSubview(field) + } + stack.addArrangedSubview(inspector) + status.font = .systemFont(ofSize: 12) + status.textColor = .secondaryLabelColor + status.lineBreakMode = .byTruncatingTail + status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + stack.addArrangedSubview(status) + for view in [bar, canvas, inspector, status] { + view.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -40).isActive = true + } + } + + private func setBusy(_ value: Bool, _ text: String) { + busy = value; canvas.isEditingEnabled = !value + buttons.forEach { $0.isEnabled = !value } + fields.forEach { $0.isEnabled = !value } + status.stringValue = text; status.toolTip = text + if !value { update() } + } + + private func command(_ arguments: [String], done: @escaping (Result<[String: Any], Error>) -> Void) { + DispatchQueue.global(qos: .utility).async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "\(Paths.slabBin)/displays") + process.arguments = arguments + // A file avoids pipe-buffer deadlocks on inventory / remote error output. + let url = FileManager.default.temporaryDirectory.appendingPathComponent("slab-layout-\(UUID().uuidString).json") + FileManager.default.createFile(atPath: url.path, contents: nil) + do { + let output = try FileHandle(forWritingTo: url) + process.standardOutput = output; process.standardError = output + defer { try? output.close(); try? FileManager.default.removeItem(at: url) } + try process.run() + let timeout = DispatchWorkItem { if process.isRunning { process.terminate() } } + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 180, execute: timeout) + process.waitUntilExit(); timeout.cancel() + let data = try Data(contentsOf: url) + guard process.terminationStatus == 0 else { + throw NSError(domain: "DesktopLayout", code: 1, userInfo: [NSLocalizedDescriptionKey: + String(data: data, encoding: .utf8) ?? "Display command failed"]) + } + let object = try JSONSerialization.jsonObject(with: data) + let result = object as? [String: Any] ?? ["result": object] + DispatchQueue.main.async { done(.success(result)) } + } catch { DispatchQueue.main.async { done(.failure(error)) } } + } + } + + private func accept(_ object: [String: Any]) { + state = object + original = object["screens"] as? [[String: Any]] ?? [] + canvas.screens = original + canvas.selected = original.isEmpty ? nil : 0 + canvas.fit(); dirty = false; busy = false + setBusy(false, "") + } + + private func load() { + setBusy(true, "Detecting displays…") + command(["seat-load"]) { [weak self] result in + guard let self else { return } + switch result { + case .success(let object): self.accept(object) + case .failure(let error): self.fail(error) + } + } + } + + private func fail(_ error: Error) { + setBusy(false, "") + status.stringValue = error.localizedDescription.replacingOccurrences(of: "\n", with: " ") + status.toolTip = error.localizedDescription + let alert = NSAlert() + alert.messageText = "Desktop Layout" + alert.informativeText = error.localizedDescription + alert.beginSheetModal(for: window) + } + + private func update() { + guard !busy else { return } + if let i = canvas.selected, canvas.screens.indices.contains(i) { + let s = canvas.screens[i] + selection.stringValue = (s["address"] as? String) ?? (s["screenName"] as? String ?? "Monitor") + for (field, key) in zip(fields, ["x", "y", "width", "height"]) { + field.stringValue = String((s[key] as? NSNumber)?.intValue ?? 0); field.isEnabled = true + } + } else { selection.stringValue = "Select a monitor"; fields.forEach { $0.isEnabled = false; $0.stringValue = "" } } + let issue = canvas.validationError() + applyButton.isEnabled = !canvas.screens.isEmpty && issue == nil && (dirty || state["applied"] as? Bool != true) + restoreButton.isEnabled = state["canRestore"] as? Bool == true + let warning = state["warning"] as? String ?? "" + status.stringValue = issue ?? (!warning.isEmpty ? warning : dirty ? "Draft · Apply changes pointer crossings. Shared edges connect automatically." + : state["applied"] as? Bool == true ? "Applied · Drag a monitor to rearrange your seat." + : "Draft from your seat map · Drag monitors into place, then Apply.") + status.toolTip = status.stringValue + canvas.needsDisplay = true + } + + func controlTextDidEndEditing(_ notification: Notification) { + guard let i = canvas.selected else { return } + let values = fields.map { Int($0.stringValue) } + guard values.allSatisfy({ $0 != nil && abs($0!) <= 100000 }), values[2]! > 0, values[3]! > 0 else { update(); return } + for (key, value) in zip(["x", "y", "width", "height"], values) { canvas.screens[i][key] = value! } + dirty = true; canvas.fit(); update() + } + @objc private func refresh() { load() } + @objc private func reset() { canvas.screens = original; canvas.fit(); dirty = false; update() } + @objc private func identify() { + let names = Set(canvas.screens.compactMap { $0["machine"] as? String }).sorted() + guard !names.isEmpty else { return } + setBusy(true, "Showing monitor addresses for 8 seconds…") + command(["seat-identify"]) { [weak self] result in + guard let self else { return } + switch result { case .success: self.setBusy(false, ""); case .failure(let error): self.fail(error) } + } + } + @objc private func apply() { + window.makeFirstResponder(canvas) + guard canvas.validationError() == nil else { return } + state["screens"] = canvas.screens + do { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("slab-seat-\(UUID().uuidString).json") + try JSONSerialization.data(withJSONObject: state).write(to: url, options: .atomic) + setBusy(true, "Applying pointer routes…") + command(["seat-apply", url.path]) { [weak self] result in + try? FileManager.default.removeItem(at: url) + guard let self else { return } + switch result { + case .success(let object): self.accept(object["seat"] as? [String: Any] ?? [:]) + case .failure(let error): self.fail(error) + } + } + } catch { fail(error) } + } + @objc private func restore() { + setBusy(true, "Restoring previous pointer routes…") + command(["seat-restore"]) { [weak self] result in + guard let self else { return } + switch result { + case .success(let object): self.accept(object["seat"] as? [String: Any] ?? [:]) + case .failure(let error): self.fail(error) + } + } + } + func windowShouldClose(_ sender: NSWindow) -> Bool { !busy } + func windowWillClose(_ notification: Notification) { Self.shared = nil } +} + +final class DisplaySeatCanvas: NSView { + var screens: [[String: Any]] = [] + var selected: Int? + var onChange: (() -> Void)? + var onSelect: (() -> Void)? + var isEditingEnabled = true + private var scale: CGFloat = 0.5 + private var offset = CGPoint.zero + private var down = CGPoint.zero + private var origin = CGPoint.zero + override var isFlipped: Bool { true } + override var acceptsFirstResponder: Bool { true } + private func rect(_ s: [String: Any]) -> CGRect { + func n(_ key: String) -> CGFloat { CGFloat((s[key] as? NSNumber)?.doubleValue ?? 0) } + return CGRect(x: n("x"), y: n("y"), width: n("width"), height: n("height")) + } + private func rendered(_ s: [String: Any]) -> CGRect { + let r = rect(s) + return CGRect(x: offset.x + r.minX * scale, y: offset.y + r.minY * scale, width: r.width * scale, height: r.height * scale) + } + func fit() { + guard !screens.isEmpty, bounds.width > 0, bounds.height > 0 else { return } + let all = screens.map(rect).reduce(CGRect.null) { $0.union($1) } + scale = max(0.01, min((bounds.width - 80) / all.width, (bounds.height - 80) / all.height)) + offset = CGPoint(x: (bounds.width - all.width * scale) / 2 - all.minX * scale, + y: (bounds.height - all.height * scale) / 2 - all.minY * scale) + needsDisplay = true + } + override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize); fit() } + func validationError() -> String? { + guard !screens.isEmpty else { return "No displays detected" } + let rects = screens.map(rect) + for i in rects.indices { + for j in rects.indices where j > i { + let overlap = rects[i].intersection(rects[j]) + if !overlap.isNull && overlap.width > 0 && overlap.height > 0 { return "Monitors overlap · Drag them apart before applying." } + } + } + var reached: Set = [0] + func touches(_ a: CGRect, _ b: CGRect) -> Bool { + ((a.maxX == b.minX || b.maxX == a.minX) && min(a.maxY, b.maxY) > max(a.minY, b.minY)) || + ((a.maxY == b.minY || b.maxY == a.minY) && min(a.maxX, b.maxX) > max(a.minX, b.minX)) + } + while true { + let before = reached + for i in rects.indices where reached.contains(where: { touches(rects[i], rects[$0]) }) { reached.insert(i) } + if before == reached { break } + } + return reached.count == rects.count ? nil : "Close the gaps · Snap each monitor to a shared edge." + } + override func draw(_ dirtyRect: NSRect) { + NSColor(calibratedWhite: 0.09, alpha: 1).setFill() + NSBezierPath(roundedRect: bounds, xRadius: 12, yRadius: 12).fill() + for (i, s) in screens.enumerated() { + let r = rendered(s).insetBy(dx: 3, dy: 3) + let chosen = selected == i + let online = s["online"] as? Bool ?? false + let shape = NSBezierPath(roundedRect: r, xRadius: 10, yRadius: 10) + (chosen ? NSColor(calibratedRed: 0.10, green: 0.32, blue: 0.34, alpha: 1) + : NSColor(calibratedWhite: online ? 0.20 : 0.14, alpha: 1)).setFill(); shape.fill() + (chosen ? NSColor.systemTeal : NSColor(calibratedWhite: 0.4, alpha: 1)).setStroke() + shape.lineWidth = chosen ? 3 : 1; shape.stroke() + let name = (s["address"] as? String) ?? (s["screenName"] as? String ?? "Display") + let number = (s["number"] as? NSNumber)?.stringValue ?? "" + label(number, at: NSRect(x: r.minX + 12, y: r.midY - 51, width: r.width - 24, height: 49), size: min(42, r.height * 0.30), weight: .bold) + label(name, at: NSRect(x: r.minX + 8, y: r.midY + 1, width: r.width - 16, height: 23), size: min(17, r.width / 11), weight: .semibold) + label(s["pixels"] as? String ?? "", at: NSRect(x: r.minX + 8, y: r.midY + 28, width: r.width - 16, height: 18), size: 11, weight: .regular, color: .lightGray) + } + // Actual overlap segments illuminate the boundary where the pointer can cross. + NSColor.systemTeal.withAlphaComponent(0.85).setStroke() + for i in screens.indices { for j in screens.indices where j > i { + let a = rect(screens[i]), b = rect(screens[j]); var p: CGPoint?, q: CGPoint? + if a.maxX == b.minX || b.maxX == a.minX { + let lo = max(a.minY,b.minY), hi = min(a.maxY,b.maxY) + if hi > lo { let x = a.maxX == b.minX ? a.maxX : b.maxX; p = CGPoint(x:x,y:lo); q = CGPoint(x:x,y:hi) } + } else if a.maxY == b.minY || b.maxY == a.minY { + let lo = max(a.minX,b.minX), hi = min(a.maxX,b.maxX) + if hi > lo { let y = a.maxY == b.minY ? a.maxY : b.maxY; p = CGPoint(x:lo,y:y); q = CGPoint(x:hi,y:y) } + } + if let p, let q { let line = NSBezierPath(); line.move(to: CGPoint(x:offset.x+p.x*scale,y:offset.y+p.y*scale)); line.line(to: CGPoint(x:offset.x+q.x*scale,y:offset.y+q.y*scale)); line.lineWidth = 2; line.stroke() } + } } + } + private func label(_ text: String, at rect: CGRect, size: CGFloat, weight: NSFont.Weight, color: NSColor = .white) { + let style = NSMutableParagraphStyle(); style.alignment = .center; style.lineBreakMode = .byTruncatingTail + (text as NSString).draw(in: rect, withAttributes: [.font: NSFont.systemFont(ofSize: size, weight: weight), .foregroundColor: color, .paragraphStyle: style]) + } + override func mouseDown(with event: NSEvent) { + guard isEditingEnabled else { return } + window?.makeFirstResponder(self) + down = convert(event.locationInWindow, from: nil) + selected = screens.indices.reversed().first { rendered(screens[$0]).contains(down) } + if let selected { origin = rect(screens[selected]).origin } + onSelect?(); needsDisplay = true + } + override func mouseDragged(with event: NSEvent) { + guard isEditingEnabled, let i = selected else { return } + let point = convert(event.locationInWindow, from: nil) + var r = rect(screens[i]); r.origin = CGPoint(x: round(origin.x + (point.x - down.x) / scale), y: round(origin.y + (point.y - down.y) / scale)) + let threshold = 12 / scale + var dx = threshold, dy = threshold + for j in screens.indices where j != i { + let other = rect(screens[j]) + for x in [other.minX, other.maxX, other.minX-r.width, other.maxX-r.width] { + if abs(x-r.minX) < abs(dx) { dx = x-r.minX } + } + for y in [other.minY, other.maxY, other.minY-r.height, other.maxY-r.height] { + if abs(y-r.minY) < abs(dy) { dy = y-r.minY } + } + } + if abs(dx) < threshold { r.origin.x += dx }; if abs(dy) < threshold { r.origin.y += dy } + screens[i]["x"] = Int(r.minX); screens[i]["y"] = Int(r.minY) + onChange?(); needsDisplay = true + } + override func mouseUp(with event: NSEvent) { fit() } + override func keyDown(with event: NSEvent) { + guard isEditingEnabled, let i = selected else { return } + let delta = event.modifierFlags.contains(.shift) ? 10 : 1 + let key: String, change: Int + switch event.keyCode { case 123: key="x"; change = -delta; case 124: key="x"; change=delta + case 125: key="y"; change=delta; case 126: key="y"; change = -delta + default: super.keyDown(with: event); return } + screens[i][key] = ((screens[i][key] as? NSNumber)?.intValue ?? 0) + change + onChange?(); needsDisplay = true + } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift index 6488be65f2..2c7880f39e 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -143,7 +143,8 @@ enum MenuBuilder { mac.addItem(.separator()) let stayAwake = item("Stay awake (lid closed)", selector: #selector(AppDelegate.toggleStayAwake), target: target) - stayAwake.state = state.sleepDisabled ? .on : .off + stayAwake.state = state.stayAwake ? (state.sleepDisabled ? .on : .mixed) : .off + stayAwake.toolTip = "Keep awake until you turn this off or choose Sleep now, including after agent work finishes." mac.addItem(stayAwake) mac.addItem(item("Sleep now", selector: #selector(AppDelegate.sleepNow), target: target)) @@ -1389,6 +1390,8 @@ enum MenuBuilder { let sub = NSMenu() let start = item("Start", selector: #selector(AppDelegate.deskflowStart), target: target) + sub.addItem(item("Desktop Layout…", selector: #selector(AppDelegate.openDisplayLayout), target: target)) + sub.addItem(.separator()) start.isEnabled = !d.running sub.addItem(start) let stop = item("Stop", selector: #selector(AppDelegate.deskflowStop), target: target) diff --git a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift index 14c174f8a2..a44cd5c9b6 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift @@ -149,6 +149,7 @@ enum Paths { static var daemonPlist: String { "\(home)/Library/LaunchAgents/computer.slab.daemon.plist" } static var menubarPlist: String { "\(home)/Library/LaunchAgents/computer.slab.menubar.plist" } static var claudeSleep: String { "\(slabBin)/claude-sleep" } + static var stayAwakeFlag: String { "\(slabHome)/state/stay-awake" } /// "Start Call" recorder + state file. The shell wrapper at /// slab/bin/slab-call-record owns the ffmpeg subprocess and writes the diff --git a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift index 2bfca96b33..700f93aaed 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift @@ -196,6 +196,7 @@ struct IrisState { struct StateSnapshot { var lidClosed: Bool = false var sleepDisabled: Bool = false + var stayAwake: Bool = false var activePrompts: Int = 0 var activeSubagents: Int = 0 var ambientActive: Bool = false @@ -265,6 +266,7 @@ struct StateSnapshot { var s = StateSnapshot() s.lidClosed = parseLidState() s.sleepDisabled = parseSleepDisabled() + s.stayAwake = FileManager.default.fileExists(atPath: Paths.stayAwakeFlag) s.activePrompts = countFiles(in: Paths.activePromptsDir) s.activeSubagents = countFiles(in: Paths.activeSubagentsDir) s.ambientActive = FileManager.default.fileExists(atPath: Paths.ambientFlag) diff --git a/slab/menubar-swift/Sources/SlabMenubar/main.swift b/slab/menubar-swift/Sources/SlabMenubar/main.swift index d1d98f743a..7c339ba42c 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/main.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/main.swift @@ -11,6 +11,12 @@ if LedgerCLI.handleIfPresent(CommandLine.arguments) { exit(0) } let app = NSApplication.shared app.setActivationPolicy(.accessory) +// Isolated editor mode is also useful for UI verification without a second menubar daemon. +if CommandLine.arguments.contains("--display-layout") { + DisplayLayoutWindow.show() + app.run() + exit(0) +} let delegate = AppDelegate() app.delegate = delegate app.run() diff --git a/slab/test/sleep-control.test.mjs b/slab/test/sleep-control.test.mjs new file mode 100644 index 0000000000..434260dd86 --- /dev/null +++ b/slab/test/sleep-control.test.mjs @@ -0,0 +1,153 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, mkdir, readFile, writeFile, symlink, rm, access } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const exec = promisify(execFile); +const helper = fileURLToPath(new URL("../bin/claude-sleep", import.meta.url)); + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), "slab sleep test ")); + t.after(() => rm(root, { recursive: true, force: true })); + const bin = join(root, "bin"); + await mkdir(bin); + const script = (name, body) => writeFile(join(bin, name), `#!/bin/bash\nset -eu\n${body}\n`, { mode: 0o755 }); + await script("sudo", '[[ "$1" == -n ]] && shift; exec "$@"'); + await script("pmset", ` +if [[ "$1" == -g ]]; then + printf 'SleepDisabled %s\\n' "$(cat "$SLAB_HOME/actual" 2>/dev/null || echo 0)" + exit 0 +fi +echo "$*" >> "$SLAB_HOME/calls" +if [[ "$1" == -a ]]; then + if [[ "\${TEST_FAIL_DISABLE:-}" == "$3" ]]; then echo "permission denied" >&2; exit 1; fi + if [[ "$3" == 1 && "\${TEST_PAUSE_ENABLE:-}" == 1 ]]; then + touch "$SLAB_HOME/entered" + while [[ ! -f "$SLAB_HOME/release" ]]; do sleep 0.01; done + fi + echo "$3" > "$SLAB_HOME/actual" +fi`); + await script("osascript", 'echo osascript >> "$SLAB_HOME/calls"'); + await script("ioreg", 'echo "AppleClamshellState = Yes"'); + for (const name of ["slab-cancel-pending", "slab-fade-ambient"]) await script(name, ":"); + await script("slab-afplay", 'echo sound >> "$SLAB_HOME/calls"'); + await symlink(helper, join(bin, "claude-sleep")); + const env = { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + SLAB_HOME: root, + SLAB_BIN: bin, + SLAB_PMSET: join(bin, "pmset"), + CLAUDE_STOP_LOG: join(root, "stop.log"), + AESEL_SESSION_ID: "", + EASEL_SESSION_ID: "", + }; + const run = (command, extra = {}) => exec(helper, [command], { env: { ...env, ...extra }, timeout: 10000 }); + const calls = async () => (await readFile(join(root, "calls"), "utf8").catch(() => "")).trim().split("\n").filter(Boolean); + const held = async () => access(join(root, "state/stay-awake")).then(() => true, () => false); + return { root, env, run, calls, held }; +} + +test("manual stay-awake survives turns and automatic sleep attempts", async (t) => { + const f = await fixture(t); + await f.run("awake"); + await f.run("work"); + assert.match((await f.run("idle")).stdout, /staying awake/); + assert.equal(await f.held(), true); + assert.equal((await f.run("status")).stdout, "SleepDisabled=1\nStayAwake=1\n"); + assert.deepEqual(await f.calls(), ["-a disablesleep 1", "-a disablesleep 1"]); +}); + +test("turn protection is temporary and manual release allows automatic sleep", async (t) => { + const f = await fixture(t); + await f.run("awake"); + await f.run("auto"); + await f.run("work"); + assert.equal((await f.run("status")).stdout, "SleepDisabled=1\nStayAwake=0\n"); + await f.run("idle"); + assert.equal(await f.held(), false); + assert.deepEqual((await f.calls()).slice(-2), ["-a disablesleep 0", "sleepnow"]); +}); + +test("explicit Sleep now releases the hold and sleeps", async (t) => { + const f = await fixture(t); + await f.run("awake"); + await f.run("now"); + assert.equal(await f.held(), false); + assert.deepEqual((await f.calls()).slice(-2), ["-a disablesleep 0", "sleepnow"]); +}); + +test("failed enable reports the error without saving a false preference", async (t) => { + const f = await fixture(t); + await assert.rejects(f.run("awake", { TEST_FAIL_DISABLE: "1" }), (error) => { + assert.match(error.stdout, /permission denied/); + return true; + }); + assert.equal(await f.held(), false); +}); + +test("failed release or explicit sleep preserves the hold and never sleeps", async (t) => { + const f = await fixture(t); + await f.run("awake"); + for (const command of ["auto", "now"]) { + await assert.rejects(f.run(command, { TEST_FAIL_DISABLE: "0" })); + assert.equal(await f.held(), true); + } + assert.equal((await f.calls()).some((call) => /sleepnow|osascript/.test(call)), false); +}); + +test("startup/wake restore reapplies only a saved manual preference", async (t) => { + const f = await fixture(t); + await f.run("restore"); + assert.deepEqual(await f.calls(), []); + await f.run("awake"); + await writeFile(join(f.root, "actual"), "0"); + await f.run("restore"); + assert.equal((await f.run("status")).stdout, "SleepDisabled=1\nStayAwake=1\n"); + await assert.rejects(f.run("restore", { TEST_FAIL_DISABLE: "1" })); + assert.equal(await f.held(), true); +}); + +test("automatic sleep cannot interleave with a manual enable", async (t) => { + const f = await fixture(t); + const awake = f.run("awake", { TEST_PAUSE_ENABLE: "1" }); + // Wait until the fake power command is inside the serialized transition. + for (let i = 0; ; i++) { + if (await access(join(f.root, "entered")).then(() => true, () => false)) break; + assert.ok(i < 200, "enable did not reach pmset"); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + const idle = f.run("idle"); + await writeFile(join(f.root, "release"), ""); + await Promise.all([awake, idle]); + assert.deepEqual(await f.calls(), ["-a disablesleep 1"]); + assert.equal(await f.held(), true); +}); + +for (const muted of [false, true]) { + test(`closed-lid completion respects manual hold (muted=${muted})`, async (t) => { + const f = await fixture(t); + await f.run("awake"); + if (muted) await writeFile(join(f.root, "state/muted"), ""); + const stop = fileURLToPath(new URL("../bin/claude-stop.sh", import.meta.url)); + // execFile closes stdin via the returned ChildProcess so the hook sees EOF. + const child = exec("/bin/bash", [stop], { env: f.env, timeout: 10000 }); + child.child.stdin.end("{}"); + await child; + assert.deepEqual(await f.calls(), ["-a disablesleep 1"]); + assert.equal(await f.held(), true); + }); +} + +test("delayed closed-lid sleep respects manual hold without a sleep announcement", async (t) => { + const f = await fixture(t); + await f.run("awake"); + const schedule = fileURLToPath(new URL("../bin/claude-sleep-schedule.sh", import.meta.url)); + await exec("/bin/bash", [schedule, "0"], { env: f.env, timeout: 10000 }); + assert.deepEqual(await f.calls(), ["-a disablesleep 1"]); + assert.equal(await f.held(), true); +}); diff --git a/system/backend/whistlegraph-speech-billing.mjs b/system/backend/whistlegraph-speech-billing.mjs index ea8d439120..a979066a7e 100644 --- a/system/backend/whistlegraph-speech-billing.mjs +++ b/system/backend/whistlegraph-speech-billing.mjs @@ -10,6 +10,11 @@ export const speechRequestKey=(user,id)=>createHash('sha256').update(user+'\0'+i const walletIn=(db,session)=>({ updateOne:(filter,update,options={})=>db.collection('ac-credit-wallets').updateOne(filter,update,{...options,session}), }); +export async function ensureSpeechBillingIndexes(db) { + const receipts=db.collection('whistlegraph-speech-requests'); + await receipts.createIndex({status:1,expiresAt:1},{name:'pending_recovery'}); + await receipts.createIndex({user:1},{name:'account_receipts'}); +} export function speechBilling(db,{now=()=>new Date(),limit=DAILY_TOKEN_BUDGET}={}) { const receipts=db.collection('whistlegraph-speech-requests'),usage=db.collection('ai-usage'); async function transaction(fn) { diff --git a/system/netlify/functions/whistlegraph-transcribe.mjs b/system/netlify/functions/whistlegraph-transcribe.mjs index 5db863258d..11a26ec65e 100644 --- a/system/netlify/functions/whistlegraph-transcribe.mjs +++ b/system/netlify/functions/whistlegraph-transcribe.mjs @@ -1,6 +1,6 @@ import {createHash} from 'node:crypto'; import {wavInput,createTranscriber} from '../../backend/whistlegraph-whisper.mjs'; -import {speechBilling,speechRequestKey} from '../../backend/whistlegraph-speech-billing.mjs'; +import {speechBilling,speechRequestKey,ensureSpeechBillingIndexes} from '../../backend/whistlegraph-speech-billing.mjs'; const reply=(statusCode,value)=>({statusCode,headers:{'Content-Type':'application/json','Cache-Control':'private, no-store','Access-Control-Allow-Origin':'*','Access-Control-Allow-Methods':'POST, OPTIONS','Access-Control-Allow-Headers':'Authorization,Content-Type'},body:JSON.stringify(value)}); export function createHandler({authorize,getHandleOrEmail,billing,transcribe,enabled=true,now=Date.now}) { // Retry results live only in memory for one minute. Durable receipts contain @@ -46,6 +46,7 @@ export async function handler(event) { if(!live)live=(async()=>{ const [{authorize,getHandleOrEmail},{connect}]=await Promise.all([import('../../backend/authorization.mjs'),import('../../backend/database.mjs')]); const {db}=await connect(),key=process.env.WHISTLEGRAPH_TRANSCRIPTION_KEY; + await ensureSpeechBillingIndexes(db); return createHandler({authorize,getHandleOrEmail,billing:speechBilling(db),transcribe:createTranscriber({apiKey:key}),enabled:!!key}); })().catch(error=>{live=null;throw error;}); try{return await(await live)(event);}catch{return reply(503,{error:'OpenAI speech unavailable'});} diff --git a/system/tests/account-deletion.test.mjs b/system/tests/account-deletion.test.mjs index d9974931bc..4e98daa623 100644 --- a/system/tests/account-deletion.test.mjs +++ b/system/tests/account-deletion.test.mjs @@ -718,3 +718,19 @@ test("the export is a signed-in download", async () => { assert.match(response.headers["Content-Disposition"], /attachment/); assert.deepEqual(e.calls, ["export", "disconnect"]); }); + + +test("speech billing receipts export without audio digests and disappear with the account", async () => { + const seed = world(); + seed["whistlegraph-speech-requests"] = [ + {_id:"speech-mine",user:SUB,braincells:320,charged:320,status:"complete",hash:"private-audio-digest",startedAt:T0}, + {_id:"speech-other",user:OTHER,braincells:40,status:"complete"}, + ]; + const db=fakeDb(seed),deps=fakeDeps(db); + const copy=await exportAccount(deps,{user,now:T0}); + assert.deepEqual(copy.records["whistlegraph-speech-requests"],[{_id:"speech-mine",braincells:320,charged:320,status:"complete",startedAt:T0}]); + assert.ok(!JSON.stringify(copy).includes("private-audio-digest")); + await requestDeletion(deps,{user,now:T0}); + await runDueDeletions(deps,{now:later(GRACE_MS)}); + assert.deepEqual(db.all("whistlegraph-speech-requests"),[seed["whistlegraph-speech-requests"][1]]); +}); diff --git a/system/tests/whistlegraph-speech.test.mjs b/system/tests/whistlegraph-speech.test.mjs index d74494fceb..96adaaaa1f 100644 --- a/system/tests/whistlegraph-speech.test.mjs +++ b/system/tests/whistlegraph-speech.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {randomUUID} from 'node:crypto'; -import {speechCost,speechBilling} from '../backend/whistlegraph-speech-billing.mjs'; +import {speechCost,speechBilling,ensureSpeechBillingIndexes} from '../backend/whistlegraph-speech-billing.mjs'; import {wavInput,createTranscriber} from '../backend/whistlegraph-whisper.mjs'; import {createHandler} from '../netlify/functions/whistlegraph-transcribe.mjs'; function wav(ms=1000) { @@ -45,6 +45,7 @@ test('Mongo transactions fence concurrent spending, replay, failure and crash re const request=(user='test-user')=>({user,handle:user,requestId:randomUUID(),audio:wav(),durationMs:1000}); try { // Materialize the three isolated collections before transactional DDL. + await ensureSpeechBillingIndexes(isolated); for(const name of ['ai-usage','ac-credit-wallets','whistlegraph-speech-requests'])await isolated.collection(name).insertOne({_id:'fixture'}); const wallets=isolated.collection('ac-credit-wallets'),usage=isolated.collection('ai-usage'),receipts=isolated.collection('whistlegraph-speech-requests'); await wallets.insertOne({_id:'test-user',balance:100}); diff --git a/toolchain/fleet/DISPLAYS.md b/toolchain/fleet/DISPLAYS.md new file mode 100644 index 0000000000..4db0b839db --- /dev/null +++ b/toolchain/fleet/DISPLAYS.md @@ -0,0 +1,126 @@ +# Displays + +`displays` inventories connected monitors, shows temporary number labels, and +previews or changes the arrangement and resolution of displays on a Mac. +`displays map` creates an editable physical seat map from JSON, including split +edges where one large monitor meets two laptops. + +```sh +bash toolchain/macos/install-displays.sh +displays list neo blueberry chicken panda frisbee +displays identify neo blueberry chicken panda frisbee --seconds 8 +displays modes neo:1 +``` + +The CLI uses the fleet's private machine registry and SSH configuration. Each +remote Mac needs `~/.local/bin/slab-displays-native`; run the installer there or +copy the compiled helper from a Mac with a compatible architecture and OS. +No background service is added. Unreachable machines report individual errors. + +Addresses such as `neo:1` pair a machine with its persistent local monitor number. +The native helper records UUID → number in +`~/.config/slab/displays/numbers.json` on that Mac. Disconnecting a monitor +does not free its number. macOS display IDs and mode IDs can change; query them +again after reconnecting. If macOS changes a monitor's UUID it receives a new +number. UUIDs are not assumed unique between machines. + +## Geometry within a Mac + +```sh +# Preview first; append --apply to change the current login session. +displays set neo:2 --x -1920 --y 0 +displays set neo:1 --mode 8 +displays layout neo /path/to/changes.json +displays restore neo /path/returned/by/apply/layout-UUID.json +``` + +Mode 8 and the second display above are examples; use the detected numbers and +available modes. A changes file is an array such as +`[{"number":1,"modeID":8},{"number":2,"x":1920,"y":0}]`. + +Inventory origins and bounds are **Quartz desktop points**, with x increasing +right and y increasing down. Negative origins are supported. Backing pixels, +refresh rate, physical dimensions reported by macOS, and rotation are separate +fields. This differs from unipointer v1's AppKit y-up coordinates; do not mix +the two without conversion. + +Layouts must include an origin at (0,0), share edges, and avoid overlaps. The +controller previews the full layout; the native helper checks it again against +the current display topology before one CoreGraphics transaction. Every apply +saves a restore file on the affected machine, then reports the observed result +and whether it matches the request. macOS may adjust a requested arrangement. +Changes last for the current login session. There is no timed auto-revert; +restore explicitly with the reported backup path and `--apply`. + +Mirroring is detected but read-only. Rotation is reported but cannot be changed. +This first version supports macOS only. Applying layouts has not been exercised +on a live multi-monitor Mac; inventory, identification, dry runs, invalid-layout +rejection, and the model's multi-monitor cases are verified independently. + +The native transaction follows Apple's +[display configuration API](https://developer.apple.com/documentation/coregraphics/cgconfiguredisplayorigin(_:_:_:_:)). + +## Physical arrangement between machines + +```sh +displays map toolchain/fleet/two-over-three.example.json --output /tmp/desktop-map.html +open /tmp/desktop-map.html +displays edges /path/to/desktop-map.json +``` + +The map numbers are seat positions, independent of a Mac's local monitor number. +Assign each to an address, drag its rectangle or edit its coordinates, and use +**Save map** to download JSON. **Reset** returns to the loaded map. Regenerate +HTML with `displays map` to reopen a saved JSON file. + +Seat coordinates describe an approximate physical arrangement, not OS pixels. +The photo-derived example closes bezel gaps to make pointer boundaries explicit; +it is not a calibrated measurement. The middle lower display's top edge splits +between the two upper displays. `displays edges` emits both source and +destination fractions for every touching boundary. Gaps have no connection. + +Saving the HTML map changes only that map. A photo alone does not establish +which Mac drives an unlabeled screen. + +## Native seat editor + +Open **Slab → Fleet & System → Deskflow → Desktop Layout…** on the controller with the `displays` +CLI installed. Drag numbered tiles to snap their edges together; arrow keys +nudge the selected tile (Shift moves ten units). The coordinate and size fields +edit the physical seat diagram. They do not change a monitor's pixel resolution. +**Identify** shows the matching seat number and machine address on each online +display. An offline configured screen retains a tile. + +**Apply** installs reciprocal Deskflow links, including partial edge ranges, +on connected Macs and reloads the active controller. Macs already offline when +the window loaded keep their tiles and are reported as pending sync. Refresh +and Apply after they return, before changing controllers; deferred writes are +not automatic. A Mac that disconnects or returns after the preview requires a +refresh. Apply requires one available active controller, one active display per +Mac, and a connected layout without overlap. Configuration hashes reject edits +made since the window loaded. +Each host saves `~/.config/slab/displays/deskflow-seat-UUID.json` before writing. +**Restore Previous** restores the preceding configuration if it has not changed +again. A failed transaction attempts to restore earlier writes and reports any +host requiring manual recovery. Per-host backups survive those failures. + +Slab navigation reads every ranged link and the physical tile centers. MacPal +and Slab use the generated Control–Option–Shift–F13…F20 shortcuts for direct +screen routing, so controller handoff does not depend on the old fixed grid. +Update Slab on the fleet and MacPal on controller-capable Macs before applying. +Without a saved native layout, existing routing remains the fallback. + +The current editor supports up to eight configured Deskflow machines. Local +multi-monitor resolution/geometry remains available through the CLI above. +Native editor development mode: `slab-menubar --display-layout`. + +## Agent tools + +The existing fleet MCP exposes `fleet_displays`, `fleet_display_identify`, +`fleet_display_modes`, `fleet_display_layout`, and `fleet_display_restore`. +Layout and restore default to previews. A layout apply can pass the `expected` +array from its preview to reject intervening geometry changes. + +```sh +node --test toolchain/fleet/display-model.test.mjs toolchain/fleet/deskflow-seat-model.test.mjs +``` diff --git a/toolchain/fleet/deskflow-seat-host.py b/toolchain/fleet/deskflow-seat-host.py new file mode 100644 index 0000000000..7bdf445b4f --- /dev/null +++ b/toolchain/fleet/deskflow-seat-host.py @@ -0,0 +1,76 @@ +"""Small stdin-only transaction helper, run locally or over authenticated SSH.""" +import fcntl, hashlib, json, os, pathlib, subprocess, sys, tempfile, time, uuid + +home = pathlib.Path.home() +root = home / '.config/slab/displays' +root.mkdir(parents=True, exist_ok=True) +config = home / 'Library/Deskflow/deskflow-handoff-server.conf' +routes = root / 'routes.json' + +def digest(text): + return hashlib.sha256(text.encode()).hexdigest() + +def atomic(path, text): + fd, tmp = tempfile.mkstemp(dir=path.parent) + try: + with os.fdopen(fd, 'w') as f: + f.write(text) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + finally: + if os.path.exists(tmp): os.unlink(tmp) + +def read(): + text = config.read_text() + return dict(config=text, hash=digest(text), + handoff=json.loads((home / '.config/slab/deskflow-handoff.json').read_text()), + role=json.loads((home / '.config/slab/deskflow.json').read_text()).get('role'), + routes=routes.read_text() if routes.exists() else None) + +def reload(): + # Clients keep their connection. Only an active server needs a reload. + state = json.loads((home / '.config/slab/deskflow.json').read_text()) + if state.get('role') != 'server': return + agent = state.get('agent', 'computer.aesthetic.deskflow') + if not all(c.isalnum() or c in '._-' for c in agent): raise ValueError('Invalid agent') + log = home / 'Library/Logs/deskflow-core.log' + offset = log.stat().st_size if log.exists() else 0 + service = 'gui/%s/%s' % (os.getuid(), agent) + subprocess.run(['/bin/launchctl', 'kill', 'SIGKILL', service], capture_output=True, timeout=5) + subprocess.run(['/bin/launchctl', 'kickstart', service], capture_output=True, timeout=5) + for _ in range(80): + with log.open(errors='replace') as f: + f.seek(offset) + tail = f.read() + if 'started server' in tail: return + if 'cannot read configuration' in tail or 'configuration error' in tail: + raise RuntimeError('Deskflow rejected the configuration') + time.sleep(.1) + raise RuntimeError('Deskflow did not report a running server within 8 seconds') + +request = json.load(sys.stdin) +with (root / 'seat.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + before = read() + if request['operation'] == 'read': + print(json.dumps(before)) + sys.exit(0) + if request.get('expected') != before['hash']: + raise RuntimeError('Deskflow changed since this window loaded; refresh first') + backup = root / ('deskflow-seat-%s.json' % uuid.uuid4()) + atomic(backup, json.dumps(before, indent=2)) + try: + atomic(config, request['config']) + if request.get('routes') is None: + routes.unlink(missing_ok=True) + else: + atomic(routes, request['routes']) + reload() + except Exception: + atomic(config, before['config']) + if before['routes'] is None: routes.unlink(missing_ok=True) + else: atomic(routes, before['routes']) + reload() + raise + print(json.dumps(dict(backup=str(backup), hash=digest(request['config'])))) diff --git a/toolchain/fleet/deskflow-seat-model.mjs b/toolchain/fleet/deskflow-seat-model.mjs new file mode 100644 index 0000000000..a8d8cab86c --- /dev/null +++ b/toolchain/fleet/deskflow-seat-model.mjs @@ -0,0 +1,80 @@ +import { validateSeat, seatEdges, addressParts } from './display-model.mjs'; + +// Only peers already shown offline in the preview may be deferred. A peer +// disappearing, returning, or changing configuration requires a fresh preview. +export async function preflightSeatHosts(expected, readHost) { + if (!Array.isArray(expected) || !expected.length) throw new Error('Refresh the seat before applying'); + if (new Set(expected.map(h => h.machine)).size !== expected.length) throw new Error('Duplicate seat host'); + const records = await Promise.all(expected.map(async h => { + let state; + try { state = await readHost(h.machine); } + catch (error) { + if (h.ok === false) return { machine: h.machine, pending: true }; + throw new Error(`${h.machine} unavailable; refresh before applying: ${error.message}`); + } + if (h.ok === false || h.hash !== state.hash) throw new Error(`${h.machine} changed; refresh before applying`); + return { ...h, state }; + })); + const hosts = records.filter(h => !h.pending); + const controllers = hosts.filter(h => h.state.role === 'server'); + if (controllers.length !== 1) throw new Error('Exactly one active controller must be available'); + return { hosts, active: controllers[0], pendingHosts: records.filter(h => h.pending).map(h => h.machine) }; +} + +export function screenNames(config) { + const section = /(?:^|\n)section: screens\s*\n([\s\S]*?)^end\s*$/m.exec(config)?.[1]; + if (!section) throw new Error('Missing Deskflow screens'); + return [...section.matchAll(/^\s*([\w.-]+):\s*$/gm)].map(m => m[1]); +} + +const keys = [105, 107, 113, 106, 64, 79, 80, 90]; // macOS F13–F20 +export function compileSeat(seat, config) { + validateSeat(seat); + const names = screenNames(config); + if (names.length > keys.length) throw new Error('The seat editor supports up to eight machines'); + const used = new Set(); + const machines = new Set(); + for (const s of seat.screens) { + if (!names.includes(s.screenName) || used.has(s.screenName)) throw new Error('Each Deskflow screen needs exactly one tile'); + used.add(s.screenName); + if (s.address) { + const { machine } = addressParts(s.address); + if (machines.has(machine)) throw new Error('Deskflow addresses a whole Mac; multiple displays on one Mac need a local layout first'); + machines.add(machine); + } + } + if (used.size !== names.length) throw new Error('Keep a tile for every configured Deskflow screen'); + const edges = seatEdges(seat.screens); + const reached = new Set([seat.screens[0].number]); + for (;;) { + const before = reached.size; + for (const edge of edges) if (reached.has(edge.from)) reached.add(edge.to); + if (before === reached.size) break; + } + if (reached.size !== seat.screens.length) throw new Error('Close the gaps: every monitor must share an edge with the seat'); + const percent = n => Math.round(n * 100); + const range = span => { + const [a, b] = span.map(percent); + if (a >= b) throw new Error('An edge is too small for Deskflow; increase its overlap'); + return a === 0 && b === 100 ? '' : `(${a},${b})`; + }; + const lines = ['section: links']; + for (const s of seat.screens) { + lines.push(`\t${s.screenName}:`); + for (const e of edges.filter(e => e.from === s.number)) { + const destination = seat.screens.find(s => s.number === e.to); + lines.push(`\t\t${e.side}${range(e.source)} = ${destination.screenName}${range(e.destination)}`); + } + } + lines.push('end'); + let output = config.replace(/^section: links\s*\n[\s\S]*?^end\s*$/m, lines.join('\n')); + if (output === config && !config.includes(lines.join('\n'))) throw new Error('Missing Deskflow links section'); + // Private routing shortcuts keep controller handoff independent of tile positions. + const routeKeys = Object.fromEntries([...names].sort().map((name, i) => [name, keys[i]])); + const start = '# slab-seat shortcuts begin', end = '# slab-seat shortcuts end'; + output = output.replace(/\n[\t ]*# slab-seat shortcuts begin[\s\S]*?# slab-seat shortcuts end\n?/g, '\n'); + const shortcuts = [...names].sort().map((name, i) => `\tkeystroke(Control+Alt+Shift+F${13 + i}) = switchToScreen(${name})`).join('\n'); + output = output.replace(/^(section: options\s*\n)/m, `$1\t${start}\n${shortcuts}\n\t${end}\n`); + if (!output.includes(start)) throw new Error('Missing Deskflow options section'); + return { config: output, edges, routeKeys }; +} diff --git a/toolchain/fleet/deskflow-seat-model.test.mjs b/toolchain/fleet/deskflow-seat-model.test.mjs new file mode 100644 index 0000000000..65fb0cc0c6 --- /dev/null +++ b/toolchain/fleet/deskflow-seat-model.test.mjs @@ -0,0 +1,73 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { compileSeat, preflightSeatHosts } from './deskflow-seat-model.mjs'; + +const config = `section: screens\n a:\n b:\n c:\nend\nsection: links\n a:\n down = b\n b:\n c:\nend\nsection: options\n clipboardSharing = true\nend\n`; +const seat = { version: 1, screens: [ + { number: 1, screenName: 'a', address: 'a:1', x: 0, y: 0, width: 600, height: 300 }, + { number: 2, screenName: 'b', address: 'b:1', x: 0, y: 300, width: 300, height: 200 }, + { number: 3, screenName: 'c', address: 'c:1', x: 300, y: 300, width: 300, height: 200 }, +] }; +test('split boundaries generate reciprocal source and destination ranges', () => { + const plan = compileSeat(seat, config); + assert.match(plan.config, /down\(0,50\) = b/); + assert.match(plan.config, /down\(50,100\) = c/); + assert.match(plan.config, /up = a\(0,50\)/); + assert.match(plan.config, /up = a\(50,100\)/); + assert.match(plan.config, /clipboardSharing = true/); + assert.equal(compileSeat(seat, plan.config).config, plan.config); + assert.deepEqual(plan.routeKeys, { a: 105, b: 107, c: 113 }); +}); +test('unknown, duplicate and missing machine tiles cannot remove existing routes', () => { + for (const screens of [seat.screens.slice(0, 2), [...seat.screens, seat.screens[0]], seat.screens.map((s, i) => i ? s : { ...s, screenName: 'intruder' })]) { + assert.throws(() => compileSeat({ version: 1, screens }, config)); + } +}); +test('gaps, overlap and sub-percent crossings are rejected', () => { + for (const x of [1000, 299, 599]) { + const draft = structuredClone(seat); draft.screens[2].x = x; + assert.throws(() => compileSeat(draft, config)); + } +}); + +test('swapping upper monitors reverses the laptop split and keeps reciprocal routes', () => { + const layout = { version: 1, screens: [ + { number: 1, screenName: 'a', x: 600, y: 0, width: 600, height: 338 }, + { number: 2, screenName: 'b', x: 0, y: 0, width: 600, height: 338 }, + { number: 3, screenName: 'c', x: 400, y: 338, width: 400, height: 250 }, + ] }; + const plan = compileSeat(layout, config); + assert.match(plan.config, /a:\n\t\tleft = b\n\t\tdown\(0,33\) = c\(50,100\)/); + assert.match(plan.config, /b:\n\t\tright = a\n\t\tdown\(67,100\) = c\(0,50\)/); + assert.match(plan.config, /c:\n\t\tup\(50,100\) = a\(0,33\)\n\t\tup\(0,50\) = b\(67,100\)/); +}); + +const expectedHosts = [ + { machine: 'controller', ok: true, hash: 'old' }, + { machine: 'client', ok: true, hash: 'old' }, + { machine: 'offline', ok: false }, +]; +const readHost = async machine => { + if (machine === 'offline') throw new Error('connection timed out'); + return { hash: 'old', role: machine === 'controller' ? 'server' : 'client' }; +}; +test('known offline peers are deferred while connected clients and controller are checked', async () => { + const result = await preflightSeatHosts(expectedHosts, readHost); + assert.deepEqual(result.pendingHosts, ['offline']); + assert.deepEqual(result.hosts.map(h => h.machine), ['controller', 'client']); + assert.equal(result.active.machine, 'controller'); +}); +test('changed, newly offline, and returning hosts require refresh before any writes', async () => { + for (const [target, replacement] of [ + ['client', async () => ({ hash: 'new', role: 'client' })], + ['client', async () => { throw new Error('disconnected'); }], + ['offline', async () => ({ hash: 'old', role: 'client' })], + ]) { + await assert.rejects(preflightSeatHosts(expectedHosts, machine => machine === target ? replacement() : readHost(machine)), /refresh before applying/); + } +}); +test('zero or multiple reachable controllers cannot apply', async () => { + for (const role of ['server', 'client']) { + await assert.rejects(preflightSeatHosts(expectedHosts, async machine => ({ ...await readHost(machine), role })), /Exactly one active controller/); + } +}); diff --git a/toolchain/fleet/deskflow-seat.mjs b/toolchain/fleet/deskflow-seat.mjs new file mode 100644 index 0000000000..87a56ca889 --- /dev/null +++ b/toolchain/fleet/deskflow-seat.mjs @@ -0,0 +1,162 @@ +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID, createHash } from 'node:crypto'; +import { displayTargets, listDisplays, run, native } from './displays.mjs'; +import { addressParts } from './display-model.mjs'; +import { compileSeat, screenNames, preflightSeatHosts } from './deskflow-seat-model.mjs'; + +const root = join(homedir(), '.config/slab/displays'); +const seatPath = join(root, 'seat.json'); +const receiptPath = join(root, 'last-seat-apply.json'); +const script = await readFile(new URL('./deskflow-seat-host.py', import.meta.url), 'utf8'); +const quote = text => `'${text.replaceAll("'", "'\\''")}'`; +const localName = (await run('/usr/sbin/scutil', ['--get', 'LocalHostName'])).trim(); + +async function host(machine, request) { + const target = displayTargets([machine])[0]; + const options = { input: JSON.stringify(request), timeout: 30000 }; + const raw = target.localNames.includes(localName) + ? await run('/usr/bin/python3', ['-c', script], options) + : await run('/usr/bin/ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', target.target, + `/usr/bin/python3 -c ${quote(script)}`], options); + return JSON.parse(raw); +} +async function atomic(path, value) { + await mkdir(root, { recursive: true }); + const temp = `${path}.${randomUUID()}.tmp`; + await writeFile(temp, JSON.stringify(value, null, 2), { mode: 0o600 }); + await rename(temp, path); +} +async function readJSON(path) { try { return JSON.parse(await readFile(path, 'utf8')); } catch { return null; } } + +export async function loadSeat() { + const local = JSON.parse(await readFile(join(homedir(), '.config/slab/deskflow-handoff.json'), 'utf8')); + const names = Object.keys(local.transportPeers ?? {}); + if (!names.length) throw new Error('No Deskflow fleet is configured'); + const [inventory, records] = await Promise.all([ + listDisplays({ machines: names }), + Promise.all(names.map(async machine => { + try { return { machine, ok: true, ...await host(machine, { operation: 'read' }) }; } + catch (error) { return { machine, ok: false, error: error.message }; } + })), + ]); + const active = records.find(r => r.ok && r.role === 'server'); + if (!active) throw new Error('The active Deskflow controller is unavailable'); + const saved = await readJSON(seatPath); + const seed = saved ?? await readJSON(join(homedir(), 'Documents/Shelf/desktop-layout/desktop-map.json')); + const configured = screenNames(active.config); + const screens = []; + for (const name of configured) { + const record = records.find(r => r.ok && r.handoff.screenName === name); + const savedScreen = seed?.screens.find(s => s.screenName === name); + const machine = record?.machine ?? savedScreen?.machine; + const found = inventory.machines.find(m => m.machine === machine && m.ok); + const displays = found?.displays.filter(d => d.active) ?? []; + const display = displays.find(d => d.main) ?? displays[0]; + const address = display?.address ?? savedScreen?.address ?? (machine ? `${machine}:1` : null); + const previous = savedScreen ?? seed?.screens.find(s => address && s.address === address); + const right = screens.reduce((right, s) => Math.max(right, s.x + s.width), 0); + screens.push({ number: previous?.number ?? screens.length + 1, screenName: name, address, + machine: machine ?? null, online: !!display, name: display?.name ?? name, + multipleDisplays: displays.length > 1, + x: previous?.x ?? right, y: previous?.y ?? 338, + width: previous?.width ?? 400, height: previous?.height ?? 250, + pixels: display ? `${display.bounds.width} × ${display.bounds.height}` : 'Offline', + }); + } + // Preserve existing seat numbers and assign new ones without collision. + const seen = new Set(); + for (const s of screens) { + if (seen.has(s.number)) s.number = Math.max(...screens.map(s => s.number)) + 1; + seen.add(s.number); + } + const receipt = await readJSON(receiptPath); + const pendingHosts = saved?.pendingHosts ?? []; + const routesMatch = saved?.applied === true && saved?.configHash === active.hash; + const warning = routesMatch && pendingHosts.length + ? `Applied on connected Macs · Pending sync: ${pendingHosts.join(', ')}. Refresh and Apply when they return, before changing controllers.` + : records.filter(r => !r.ok).map(r => `${r.machine} unavailable`).join('; '); + return { version: 1, screens, hosts: records.map(r => ({ machine: r.machine, ok: r.ok, + hash: r.hash, controller: r.handoff?.controller ?? saved?.hosts?.find(h => h.machine === r.machine)?.controller ?? false, error: r.error })), + applied: routesMatch && !records.some(r => r.ok && pendingHosts.includes(r.machine)), pendingHosts, + canRestore: !!receipt, controller: active.machine, + warning }; +} + +export async function applySeat(seat) { + if (!Array.isArray(seat.hosts) || !seat.hosts.length) throw new Error('Refresh the seat before applying'); + if (seat.screens.some(s => s.multipleDisplays)) throw new Error('This editor currently supports one active display per machine'); + const { hosts, active, pendingHosts } = await preflightSeatHosts(seat.hosts, + machine => host(machine, { operation: 'read' })); + const plan = compileSeat(seat, active.state.config); + const routes = JSON.stringify({ version: 1, keys: plan.routeKeys, screens: seat.screens }); + const changes = hosts.map(h => ({ machine: h.machine, before: h.state, + // Keep each host's settings; replace only links and our shortcut block. + config: h.state.handoff.controller ? compileSeat(seat, h.state.config).config : plan.config })); + // Update standby controllers and cached client geometry first, active server last. + changes.sort((a, b) => Number(a.machine === active.machine) - Number(b.machine === active.machine)); + const completed = [], attempted = []; + const journal = join(root, `seat-transaction-${randomUUID()}.json`); + const receipt = { version: 1, beforeSeat: await readJSON(seatPath), changes: completed, pendingHosts }; + await atomic(journal, { status: 'prepared', changes, pendingHosts }); + try { + for (const change of changes) { + attempted.push({ ...change, hash: createHash('sha256').update(change.config).digest('hex') }); + await atomic(journal, { status: 'applying', attempted, completed }); + const result = await host(change.machine, { operation: 'write', expected: change.before.hash, config: change.config, routes }); + completed.push({ ...change, ...result }); + } + await atomic(seatPath, { ...seat, applied: true, pendingHosts, configHash: plan.config === active.state.config ? active.state.hash : completed.find(c => c.machine === active.machine).hash }); + await atomic(receiptPath, receipt); + await atomic(journal, { status: 'complete', ...receipt }); + } catch (error) { + const failed = []; + for (const change of [...attempted].reverse()) { + try { + // A lost SSH response is an unknown outcome: inspect before rolling back. + const current = await host(change.machine, { operation: 'read' }); + if (current.hash === change.before.hash && current.routes === change.before.routes) continue; + if (current.hash !== change.hash) throw new Error('Configuration changed again; saved backup requires review'); + await host(change.machine, { operation: 'write', expected: change.hash, config: change.before.config, routes: change.before.routes }); + } + catch (rollback) { failed.push(`${change.machine}: ${rollback.message}`); } + } + if (!failed.length) await atomic(seatPath, receipt.beforeSeat); + await atomic(journal, { status: failed.length ? 'recovery-needed' : 'rolled-back', attempted, failed }); + throw new Error(`${error.message}. ${failed.length ? `Restore required: ${failed.join('; ')}. Recovery record: ${journal}` : 'Earlier changes restored.'}`); + } + return { applied: true, pendingHosts, backups: completed.map(c => ({ machine: c.machine, path: c.backup })), seat: await loadSeat() }; +} + +export async function identifySeat() { + const seat = await loadSeat(); + const results = await Promise.all(seat.screens.filter(s => s.online && s.address).map(async s => { + const { machine, number } = addressParts(s.address); + return native(displayTargets([machine])[0], ['identify', '8', String(number), String(s.number)]); + })); + return { identified: results.length }; +} + +export async function restoreSeat() { + const receipt = await readJSON(receiptPath); + if (!receipt?.changes?.length) throw new Error('No saved layout to restore'); + // Preflight all hosts before making changes; avoid silently replacing newer edits. + for (const change of receipt.changes) { + const current = await host(change.machine, { operation: 'read' }); + if (current.hash !== change.hash && current.hash !== change.before.hash) throw new Error(`${change.machine} has newer changes; automatic restore refused`); + } + const failures = []; + for (const change of [...receipt.changes].reverse()) { + try { + const current = await host(change.machine, { operation: 'read' }); + if (current.hash === change.before.hash && current.routes === change.before.routes) continue; + await host(change.machine, { operation: 'write', expected: change.hash, config: change.before.config, routes: change.before.routes }); + } + catch (error) { failures.push(`${change.machine}: ${error.message}`); } + } + if (failures.length) throw new Error(`Restore incomplete: ${failures.join('; ')}`); + await atomic(seatPath, receipt.beforeSeat ?? { version: 1, screens: [], applied: false }); + await atomic(receiptPath, null); + return { restored: true, seat: await loadSeat() }; +} diff --git a/toolchain/fleet/display-map.html b/toolchain/fleet/display-map.html new file mode 100644 index 0000000000..86388ccf13 --- /dev/null +++ b/toolchain/fleet/display-map.html @@ -0,0 +1,135 @@ + + + + +Desktop map + +

Desktop map

+

+ +
+
+ + + + + + + +
+
+
+

Drag to arrange; nearby edges snap together. Arrow keys move the selected screen (Shift = 10). These are seat coordinates. Saving exports JSON; it does not change displays or pointer routing.

+ + diff --git a/toolchain/fleet/display-model.mjs b/toolchain/fleet/display-model.mjs new file mode 100644 index 0000000000..967cb5b76d --- /dev/null +++ b/toolchain/fleet/display-model.mjs @@ -0,0 +1,106 @@ +export function addressParts(address) { + const match = /^([a-zA-Z0-9][a-zA-Z0-9.-]*):([1-9][0-9]*)$/.exec(address); + if (!match || !Number.isSafeInteger(Number(match[2]))) throw new Error("Expected a display address such as neo:1"); + return { machine: match[1], number: Number(match[2]) }; +} + +export function snapshot(inventory) { + return inventory.displays.filter(d => d.active).map(d => { + if (!d.mode) throw new Error(`No current mode for display ${d.number}`); + return { uuid: d.uuid, x: d.bounds.x, y: d.bounds.y, modeID: d.mode.id, rotation: d.rotation }; + }).sort((a, b) => a.uuid.localeCompare(b.uuid)); +} + +export function validateRectangles(rects) { + if (!rects.length) throw new Error("Layout is empty"); + for (const r of rects) { + for (const key of ["x", "y", "width", "height"]) { + if (!Number.isInteger(r[key]) || Math.abs(r[key]) > 100000) throw new Error(`Invalid ${key}`); + } + if (r.width <= 0 || r.height <= 0) throw new Error("Dimensions must be positive"); + } + for (let i = 0; i < rects.length; i++) for (let j = i + 1; j < rects.length; j++) { + const a = rects[i], b = rects[j]; + if (Math.min(a.x + a.width, b.x + b.width) > Math.max(a.x, b.x) + && Math.min(a.y + a.height, b.y + b.height) > Math.max(a.y, b.y)) throw new Error("Displays overlap"); + } +} + +export function planLayout(inventory, changes, modes = new Map()) { + if (inventory.displays.some(d => d.mirrored)) throw new Error("Mirrored layouts are read-only"); + const expected = snapshot(inventory); + const layout = expected.map(p => ({ ...p })); + const seen = new Set(); + for (const change of changes) { + if (seen.has(change.number)) throw new Error("Duplicate display change"); + seen.add(change.number); + const d = inventory.displays.find(d => d.number === change.number && d.active); + if (!d) throw new Error(`Display ${change.number} is not active`); + for (const key of Object.keys(change)) if (!["number", "x", "y", "modeID"].includes(key)) throw new Error(`Unknown layout field: ${key}`); + const p = layout.find(p => p.uuid === d.uuid); + for (const key of ["x", "y", "modeID"]) if (change[key] !== undefined) { + if (!Number.isInteger(change[key])) throw new Error(`${key} must be an integer`); + p[key] = change[key]; + } + } + const rects = layout.map(p => { + const d = inventory.displays.find(d => d.uuid === p.uuid); + const mode = p.modeID === d.mode.id ? d.mode : modes.get(d.number)?.find(m => m.id === p.modeID); + if (!mode) throw new Error(`Unavailable mode ${p.modeID} for display ${d.number}`); + // Use the observed frame for unchanged modes, including rotated displays. + const portrait = d.rotation % 180 !== 0; + const width = p.modeID === d.mode.id ? d.bounds.width : portrait ? mode.height : mode.width; + const height = p.modeID === d.mode.id ? d.bounds.height : portrait ? mode.width : mode.height; + return { x: p.x, y: p.y, width, height }; + }); + validateRectangles(rects); + if (!rects.some(r => r.x === 0 && r.y === 0)) throw new Error("One display must start at (0,0)"); + const edges = seatEdges(rects.map((r, i) => ({ ...r, number: i + 1 }))); + const reached = new Set([1]); + for (;;) { + const size = reached.size; + for (const e of edges) if (reached.has(e.from)) reached.add(e.to); + if (reached.size === size) break; + } + if (reached.size !== rects.length) throw new Error("Displays must share edges; close the gaps"); + return { expected, layout }; +} + +// Physical seat coordinates are distinct from each Mac's Quartz coordinates. +// Every shared edge records both spans, so a wide panel can meet two laptops. +export function seatEdges(screens) { + validateRectangles(screens); + const edges = []; + for (const a of screens) for (const b of screens) if (a !== b) { + let side, lo, hi, startA, startB, sizeA, sizeB; + if (a.x + a.width === b.x || b.x + b.width === a.x) { + side = a.x + a.width === b.x ? "right" : "left"; + lo = Math.max(a.y, b.y); hi = Math.min(a.y + a.height, b.y + b.height); + startA = a.y; startB = b.y; sizeA = a.height; sizeB = b.height; + } else if (a.y + a.height === b.y || b.y + b.height === a.y) { + side = a.y + a.height === b.y ? "down" : "up"; + lo = Math.max(a.x, b.x); hi = Math.min(a.x + a.width, b.x + b.width); + startA = a.x; startB = b.x; sizeA = a.width; sizeB = b.width; + } + if (side && hi > lo) edges.push({ from: a.number, to: b.number, side, + source: [(lo - startA) / sizeA, (hi - startA) / sizeA], + destination: [(lo - startB) / sizeB, (hi - startB) / sizeB] }); + } + return edges; +} + +export function validateSeat(seat) { + if (seat.version !== 1 || !Array.isArray(seat.screens)) throw new Error("Expected a version 1 seat map"); + const numbers = new Set(), addresses = new Set(); + for (const s of seat.screens) { + if (!Number.isInteger(s.number) || s.number < 1 || numbers.has(s.number)) throw new Error("Seat numbers must be unique positive integers"); + numbers.add(s.number); + if (s.address !== null && s.address !== undefined) { + addressParts(s.address); + if (addresses.has(s.address)) throw new Error("Duplicate display address"); + addresses.add(s.address); + } + } + validateRectangles(seat.screens); + return seat; +} diff --git a/toolchain/fleet/display-model.test.mjs b/toolchain/fleet/display-model.test.mjs new file mode 100644 index 0000000000..446c27d5cf --- /dev/null +++ b/toolchain/fleet/display-model.test.mjs @@ -0,0 +1,64 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { addressParts, planLayout, seatEdges, validateSeat } from "./display-model.mjs"; + +const display = (number, x, width = 1440) => ({ number, uuid: `panel-${number}`, active: true, mirrored: false, rotation: 0, + bounds: { x, y: 0, width, height: 900 }, mode: { id: 4, width, height: 900, pixelWidth: width * 2, pixelHeight: 1800 } }); + +test("addresses reject shell syntax, zero, fractions, and missing machine names", () => { + assert.deepEqual(addressParts("neo:2"), { machine: "neo", number: 2 }); + for (const value of ["neo:0", "neo:1.5", ":1", "neo:1;whoami", "-neo:1", "neo:999999999999999999999"]) assert.throws(() => addressParts(value)); +}); + +test("negative origins are valid and use logical points rather than backing pixels", () => { + const inv = { displays: [display(1, 0), display(3, 1440)] }; + const plan = planLayout(inv, [{ number: 3, x: -1440, y: 0 }]); + assert.equal(plan.layout[1].x, -1440); + assert.equal(plan.expected[1].x, 1440); + assert.equal(inv.displays[1].bounds.x, 1440); +}); + +test("reject overlap, gaps, corner-only contact, missing origin, and inactive panels", () => { + const inv = { displays: [display(1, 0), display(2, 1440), { ...display(7, 0), active: false }] }; + for (const change of [{ number: 2, x: 1000 }, { number: 2, x: 1500 }, { number: 2, x: 1440, y: 900 }, { number: 7, x: 0 }]) assert.throws(() => planLayout(inv, [change])); + assert.throws(() => planLayout(inv, [{ number: 1, x: -10 }, { number: 2, x: 1430 }]), /0,0/); + assert.throws(() => planLayout(inv, [{ number: 1, rotation: 90 }]), /Unknown/); +}); + +test("resolution changes require an available mode and a coherent whole layout", () => { + const inv = { displays: [display(1, 0), display(2, 1440)] }; + const modes = new Map([[1, [{ id: 8, width: 1920, height: 1080 }]]]); + assert.throws(() => planLayout(inv, [{ number: 1, modeID: 8 }]), /Unavailable/); + assert.throws(() => planLayout(inv, [{ number: 1, modeID: 8 }], modes), /overlap/); + const plan = planLayout(inv, [{ number: 1, modeID: 8 }, { number: 2, x: 1920 }], modes); + assert.equal(plan.layout[0].modeID, 8); + assert.equal(plan.layout[1].x, 1920); +}); + +test("mirroring is not silently disabled; duplicate changes are rejected", () => { + assert.throws(() => planLayout({ displays: [{ ...display(1, 0), mirrored: true }] }, [{ number: 1, x: 0 }]), /Mirrored/); + assert.throws(() => planLayout({ displays: [display(1, 0)] }, [{ number: 1 }, { number: 1 }]), /Duplicate/); +}); + +test("photo layout connects the middle laptop to both upper monitors", async () => { + const seat = validateSeat(JSON.parse(await readFile(new URL("./two-over-three.example.json", import.meta.url), "utf8"))); + const edges = seatEdges(seat.screens); + assert.deepEqual(edges.filter(e => e.from === 4 && e.side === "up"), [ + { from: 4, to: 1, side: "up", source: [0, 0.5], destination: [2 / 3, 1] }, + { from: 4, to: 2, side: "up", source: [0.5, 1], destination: [0, 1 / 3] }, + ]); + for (const e of edges) { + const reverse = edges.find(r => r.from === e.to && r.to === e.from); + assert.deepEqual(reverse.source, e.destination); + assert.deepEqual(reverse.destination, e.source); + } +}); + +test("seat maps reject overlapping panels, duplicate numbers, and duplicate addresses", () => { + const seat = { version: 1, screens: [{ number: 1, address: "neo:1", x: 0, y: 0, width: 100, height: 100 }, { number: 2, address: "panda:1", x: 100, y: 0, width: 100, height: 100 }] }; + assert.equal(validateSeat(seat), seat); + for (const change of [{ number: 1 }, { address: "neo:1" }, { x: 99 }]) { + assert.throws(() => validateSeat({ ...seat, screens: [seat.screens[0], { ...seat.screens[1], ...change }] })); + } +}); diff --git a/toolchain/fleet/displays.mjs b/toolchain/fleet/displays.mjs new file mode 100644 index 0000000000..2bc9cf9991 --- /dev/null +++ b/toolchain/fleet/displays.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadRegistry } from "./registry.mjs"; +import { addressParts, planLayout, snapshot, validateSeat, seatEdges } from "./display-model.mjs"; + +export function run(command, args, { input = "", timeout = 15000 } = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { timeout, stdio: ["pipe", "pipe", "pipe"] }); + let stdout = "", stderr = ""; + child.stdout.on("data", d => { stdout += d; }); + child.stderr.on("data", d => { stderr += d; }); + child.stdin.on("error", () => {}); + child.on("error", reject); + child.on("close", code => code === 0 ? resolve(stdout) : reject(new Error(stderr.trim() || `Command exited ${code}`))); + child.stdin.end(input); + }); +} + +export function displayTargets(names) { + const { machines } = loadRegistry(); + if (names !== undefined && (!Array.isArray(names) || !names.length)) throw new Error("machines must be a nonempty array"); + const requested = names ?? Object.keys(machines).filter(name => /macos/i.test(machines[name].os || "") && machines[name].tailscale); + return [...new Set(requested)].map(name => { + const entry = Object.entries(machines).find(([key, m]) => key === name || m.tailscale?.name === name); + if (!entry) throw new Error(`Unknown fleet machine: ${name}`); + const [key, m] = entry; + if (!/macos/i.test(m.os || "")) throw new Error(`${key} is not a supported Mac`); + const host = m.ssh?.alias || m.tailscale?.ip || key; + const target = m.ssh?.alias ? host : m.user ? `${m.user}@${host}` : host; + if (!/^[a-zA-Z0-9][a-zA-Z0-9.@:-]*$/.test(target)) throw new Error("Invalid registry SSH target"); + return { name: key, localNames: [key, m.tailscale?.name], target }; + }); +} +let localName; +export async function native(machine, args, input) { + localName ??= (await run("/usr/sbin/scutil", ["--get", "LocalHostName"])).trim(); + const options = { input: input === undefined ? "" : JSON.stringify(input), timeout: args[0] === "identify" ? (Number(args[1]) + 10) * 1000 : 15000 }; + let raw; + if (machine.localNames.includes(localName)) { + raw = await run(join(homedir(), ".local/bin/slab-displays-native"), args, options); + } else { + // Only allowlisted verbs, integers, and generated backup names cross SSH. + if (!args.every(a => /^[a-zA-Z0-9.-]+$/.test(String(a)))) throw new Error("Invalid native argument"); + raw = await run("ssh", ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", machine.target, + `.local/bin/slab-displays-native ${args.join(" ")}`], options); + } + return JSON.parse(raw); +} + +export async function listDisplays({ machines } = {}) { + const targets = displayTargets(machines); + const results = await Promise.all(targets.map(async m => { + try { + const inv = await native(m, ["list"]); + return { machine: m.name, ok: true, ...inv, displays: inv.displays.map(d => ({ ...d, address: `${m.name}:${d.number}` })) }; + } catch (e) { return { machine: m.name, ok: false, error: e.message }; } + })); + return { version: 1, kind: "fleet-displays", machines: results }; +} + +export async function identifyDisplays({ machines, address, seconds = 8 } = {}) { + if (!Number.isFinite(seconds) || seconds < 1 || seconds > 30) throw new Error("seconds must be 1–30"); + if (address && machines) throw new Error("Provide address or machines, not both"); + const parsed = address ? addressParts(address) : null; + const targets = displayTargets(parsed ? [parsed.machine] : machines); + return Promise.all(targets.map(async m => { + try { return { machine: m.name, ok: true, ...await native(m, ["identify", String(seconds), ...(parsed ? [String(parsed.number)] : [])]) }; } + catch (e) { return { machine: m.name, ok: false, error: e.message }; } + })); +} + +export async function displayModes({ address }) { + const { machine, number } = addressParts(address); + return native(displayTargets([machine])[0], ["modes", String(number)]); +} + +export async function layoutDisplays({ machine, changes, apply = false, expected } = {}) { + if (typeof apply !== "boolean") throw new Error("apply must be a boolean"); + if (!Array.isArray(changes) || !changes.length) throw new Error("Provide display changes"); + const target = displayTargets([machine])[0]; + const inv = await native(target, ["list"]); + const modes = new Map(); + for (const change of changes) if (change.modeID !== undefined) { + if (!Number.isInteger(change.number) || change.number < 1) throw new Error("Invalid display number"); + modes.set(change.number, await native(target, ["modes", String(change.number)])); + } + const request = planLayout(inv, changes, modes); + if (expected !== undefined) { + if (!Array.isArray(expected)) throw new Error("expected must be the array returned by a preview"); + const normalized = expected.map(p => ({ uuid: p.uuid, x: p.x, y: p.y, modeID: p.modeID, rotation: p.rotation })).sort((a, b) => String(a.uuid).localeCompare(String(b.uuid))); + if (JSON.stringify(normalized) !== JSON.stringify(request.expected)) throw new Error("Geometry changed since preview; preview again"); + } + if (!apply) return { machine: target.name, applied: false, ...request }; + return { machine: target.name, applied: true, ...await native(target, ["apply"], request) }; +} + +export async function restoreDisplays({ machine, backup, apply = false }) { + if (typeof apply !== "boolean") throw new Error("apply must be a boolean"); + const name = basename(backup || ""); + if (!/^layout-[A-Fa-f0-9-]{36}\.json$/.test(name)) throw new Error("Expected the backup path returned by apply"); + const target = displayTargets([machine])[0]; + const layout = await native(target, ["saved", name]); + const inv = await native(target, ["list"]); + const current = snapshot(inv); + if (JSON.stringify(layout.map(p => p.uuid).sort()) !== JSON.stringify(current.map(p => p.uuid).sort())) throw new Error("Connected displays differ from the backup"); + const request = { expected: current, layout }; + if (!apply) return { machine: target.name, applied: false, ...request }; + return { machine: target.name, applied: true, ...await native(target, ["apply"], request) }; +} + +const usage = `displays list [MACHINE ...] [--json] +displays identify [MACHINE ... | MACHINE:NUMBER] [--seconds 8] +displays modes MACHINE:NUMBER +displays set MACHINE:NUMBER [--x N] [--y N] [--mode N] [--apply] +displays layout MACHINE FILE.json [--apply] +displays restore MACHINE BACKUP [--apply] +displays map FILE.json --output FILE.html +displays edges FILE.json + +set/layout/restore preview by default. --apply changes the current login session. +Seat maps use their own physical canvas; moving a map rectangle does not move macOS displays.`; + +async function main(args) { + const command = args.shift() ?? "list"; + if (['seat-load', 'seat-apply', 'seat-restore', 'seat-identify'].includes(command)) { + const seat = await import('./deskflow-seat.mjs'); + const result = command === 'seat-load' ? await seat.loadSeat() + : command === 'seat-identify' ? await seat.identifySeat() + : command === 'seat-restore' ? await seat.restoreSeat() + : await seat.applySeat(JSON.parse(await readFile(args[0], 'utf8'))); + console.log(JSON.stringify(result)); + return; + } + const options = {}; + const positional = []; + const allowed = { list: ["json"], identify: ["seconds"], modes: [], set: ["x", "y", "mode", "apply"], layout: ["apply"], restore: ["apply"], map: ["output"], edges: [] }; + if (["help", "--help", "-h"].includes(command)) { console.log(usage); return; } + if (!allowed[command]) throw new Error(usage); + while (args.length) { + const arg = args.shift(); + if (!arg.startsWith("--")) { positional.push(arg); continue; } + const key = arg.slice(2); + if (!allowed[command].includes(key) || key in options) throw new Error(`Unknown or repeated option: ${arg}`); + options[key] = ["json", "apply"].includes(key) ? true : args.shift(); + if (options[key] === undefined) throw new Error(`Missing value for ${arg}`); + } + let result; + switch (command) { + case "list": + result = await listDisplays({ machines: positional.length ? positional : undefined }); + if (!options.json) { + console.log(result.machines.flatMap(m => m.ok ? m.displays.map(d => `${d.address.padEnd(14)} ${d.name} ${d.bounds.width}×${d.bounds.height} @ ${d.bounds.x},${d.bounds.y}${d.main ? " main" : ""}${d.mirrored ? " mirrored" : ""}`) : [`${m.machine}: ${m.error}`]).join("\n")); + if (result.machines.some(m => !m.ok)) process.exitCode = 1; + return; + } + break; + case "identify": + if (positional.some(p => p.includes(":")) && positional.length !== 1) throw new Error("Identify one address or a list of machines"); + result = await identifyDisplays({ ...(positional[0]?.includes(":") ? { address: positional[0] } : { machines: positional.length ? positional : undefined }), seconds: options.seconds === undefined ? 8 : Number(options.seconds) }); + break; + case "modes": + if (positional.length !== 1) throw new Error(usage); + result = await displayModes({ address: positional[0] }); break; + case "set": { + if (positional.length !== 1) throw new Error(usage); + const { machine, number } = addressParts(positional[0]); + const change = { number }; + for (const [flag, field] of [["x", "x"], ["y", "y"], ["mode", "modeID"]]) if (options[flag] !== undefined) change[field] = Number(options[flag]); + if (Object.keys(change).length === 1) throw new Error("set requires --x, --y, or --mode"); + result = await layoutDisplays({ machine, changes: [change], apply: options.apply ?? false }); break; + } + case "layout": + if (positional.length !== 2) throw new Error(usage); + result = await layoutDisplays({ machine: positional[0], changes: JSON.parse(await readFile(positional[1], "utf8")), apply: options.apply ?? false }); break; + case "restore": + if (positional.length !== 2) throw new Error(usage); + result = await restoreDisplays({ machine: positional[0], backup: positional[1], apply: options.apply ?? false }); break; + case "map": { + if (positional.length !== 1 || !options.output) throw new Error(usage); + const seat = validateSeat(JSON.parse(await readFile(positional[0], "utf8"))); + const template = await readFile(new URL("./display-map.html", import.meta.url), "utf8"); + await writeFile(options.output, template.replace("/*SEAT_DATA*/ null", JSON.stringify(seat).replaceAll("<", "\\u003c"))); + result = { output: resolve(options.output) }; break; + } + case "edges": + if (positional.length !== 1) throw new Error(usage); + result = seatEdges(validateSeat(JSON.parse(await readFile(positional[0], "utf8"))).screens); break; + } + console.log(JSON.stringify(result, null, 2)); + if (result?.machines?.some(m => !m.ok) || (Array.isArray(result) && result.some(m => m.ok === false))) process.exitCode = 1; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)).catch(e => { console.error(`displays: ${e.message}`); process.exitCode = 1; }); +} diff --git a/toolchain/fleet/fleet-mcp.mjs b/toolchain/fleet/fleet-mcp.mjs index 4e18e91fca..fee9121211 100644 --- a/toolchain/fleet/fleet-mcp.mjs +++ b/toolchain/fleet/fleet-mcp.mjs @@ -200,6 +200,20 @@ async function toolCleaner({ apply = true, thinSnapshots = false, remoteBacked = }); } +// MacPal fleet deploy — wraps macpal/bin/fleet-deploy.sh (public-safe; host lists stay in config/env). +function toolMacpalDeploy({ hosts = [], dryRun = false, statusOnly = false, force = false } = {}) { + const script = join(REPO, "macpal", "bin", "fleet-deploy.sh"); + if (!existsSync(script)) throw new Error(`missing ${script}`); + const args = [...(dryRun ? ["--dry-run"] : []), ...(statusOnly ? ["--status"] : []), ...hosts.map(String)]; + return new Promise((resolve, reject) => { + execFile(script, args, { timeout: 15 * 60_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, FORCE: force ? "1" : "0" } }, (error, stdout, stderr) => { + const text = [stdout, stderr].filter(Boolean).join("\n").trim(); + if (error) return reject(new Error(text || error.message)); + resolve([{ type: "text", text }]); + }); + }); +} + const TOOLS = [ { name: "fleet_list", @@ -221,6 +235,16 @@ const TOOLS = [ description: "Explain the controlled vocabularies: the fleet designations (agent-endpoint, compute-node, control, build, service, display, legacy) and the capability tags. Read this to know what fleet_find accepts.", inputSchema: { type: "object", properties: {} }, }, + { + name: "fleet_macpal_deploy", + description: "Deploy this checkout's MacPal (the desktop pal/badge app) to fleet Macs and relaunch it there: streams macpal/ over ssh, builds on the target, installs with ditto, re-signs ad-hoc, bootstraps/kickstarts the launchd agent, and verifies the installed version matches Resources/Info.plist. Runs hosts in parallel. hosts default to $MACPAL_FLEET or ~/.config/macpal/fleet. dryRun shows the plan; statusOnly reports installed vs source version.", + inputSchema: { type: "object", properties: { + hosts: { type: "array", items: { type: "string" }, description: "ssh host aliases, e.g. [\"panda\",\"chicken\"]. Omit to use the configured fleet list." }, + dryRun: { type: "boolean", description: "Plan only, change nothing (default false)." }, + statusOnly: { type: "boolean", description: "Only report installed version + running state per host (default false)." }, + force: { type: "boolean", description: "Redeploy even if the host already runs the source version (default false)." }, + } }, + }, { name: "fleet_cleaner", description: "Call the Cleaner on the local fleet Mac. By default applies canonical safe caches; set apply=false for a full storage-surface inventory. It protects repositories, worktrees, node_modules, Downloads, models, agent state, and active-app caches. APFS snapshots and verified remote-backed AC media are separately opt-in.", @@ -238,6 +262,7 @@ const HANDLERS = { fleet_find: toolFind, fleet_designations: toolDesignations, fleet_cleaner: toolCleaner, + fleet_macpal_deploy: toolMacpalDeploy, }; async function handleMessage(message) { @@ -272,7 +297,7 @@ async function handleMessage(message) { // Allow a quick CLI smoke test: `node fleet-mcp.mjs list|find |machine `. if (process.argv[2] && process.argv[2] !== "--http") { const [cmd, arg] = process.argv.slice(2); - const map = { list: () => toolList(), find: () => toolFind({ capability: arg }), machine: () => toolMachine({ name: arg }), designations: () => toolDesignations(), cleaner: () => toolCleaner({ apply: arg !== "report" }) }; + const map = { list: () => toolList(), find: () => toolFind({ capability: arg }), machine: () => toolMachine({ name: arg }), designations: () => toolDesignations(), cleaner: () => toolCleaner({ apply: arg !== "report" }), "macpal-deploy": () => toolMacpalDeploy({ hosts: process.argv.slice(3).filter(a=>!a.startsWith("--")), dryRun: process.argv.includes("--dry-run"), statusOnly: process.argv.includes("--status") }) }; const fn = map[cmd]; if (!fn) { console.error("usage: fleet-mcp.mjs [list | find | machine | designations | cleaner [report]]"); @@ -282,5 +307,5 @@ if (process.argv[2] && process.argv[2] !== "--http") { } else { const port = httpPort(process.argv, 7776); if (port) serveHttp({ handleMessage, port, banner: "🛰 fleet-mcp shared daemon" }); - else serveStdio({ handleMessage, banner: "🛰 fleet-mcp server started (fleet_list, fleet_machine, fleet_find, fleet_designations, fleet_cleaner)" }); + else serveStdio({ handleMessage, banner: "🛰 fleet-mcp server started (fleet_list, fleet_machine, fleet_find, fleet_designations, fleet_cleaner, fleet_macpal_deploy)" }); } diff --git a/toolchain/fleet/normalize-machines.mjs b/toolchain/fleet/normalize-machines.mjs index 1417efd483..ec07c76827 100755 --- a/toolchain/fleet/normalize-machines.mjs +++ b/toolchain/fleet/normalize-machines.mjs @@ -138,10 +138,10 @@ const ENRICH = { }, poorslice: { designation: "compute-node", - capabilities: ["mlx", "macos-automation", "screen-capture", "chromium-pool", "ffmpeg-render", "always-on"], + capabilities: ["mlx", "macos-automation", "screen-capture", "chromium-pool", "ffmpeg-render", "always-on", "build-macos", "build-ios"], tailscale: { name: "poorslice", ip: "100.86.206.3" }, status: { source: "tailscale", key: "poorslice" }, - fleetRole: "Always-on macOS media-gen compute node (M1 Pro/16GB): Chromium pool, MLX local models, screen + native-app automation. Also the strongest agent-endpoint CANDIDATE among the Macs.", + fleetRole: "Always-on macOS compute and build node (M1 Pro/16GB): Xcode macOS/iOS builds and tests, Chromium, MLX, and media rendering. Device signing happens on the control Mac.", }, }; diff --git a/toolchain/fleet/registry.mjs b/toolchain/fleet/registry.mjs new file mode 100644 index 0000000000..0882fc6169 --- /dev/null +++ b/toolchain/fleet/registry.mjs @@ -0,0 +1,17 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repo = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +export function loadRegistry() { + const candidates = [ + process.env.FLEET_MACHINES, + ...[join(repo, "aesthetic-computer-vault"), join(repo, "vault"), join(homedir(), "aesthetic-computer-vault")] + .flatMap(root => [join(root, "machines.normalized.json"), join(root, "machines.json")]), + ].filter(Boolean); + const path = candidates.find(p => existsSync(p)); + if (!path) throw new Error(`No machine registry found (looked in: ${candidates.join(", ")})`); + const data = JSON.parse(readFileSync(path, "utf8")); + return { path, machines: data.machines || {}, schema: data._schema || null }; +} diff --git a/toolchain/fleet/two-over-three.example.json b/toolchain/fleet/two-over-three.example.json new file mode 100644 index 0000000000..4f2009a747 --- /dev/null +++ b/toolchain/fleet/two-over-three.example.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "units": "seat-units", + "note": "Estimated from the photo: two wide monitors above three laptops. Assign each number to its machine; dimensions are approximate.", + "screens": [ + { "number": 1, "address": null, "x": 0, "y": 0, "width": 600, "height": 338 }, + { "number": 2, "address": null, "x": 600, "y": 0, "width": 600, "height": 338 }, + { "number": 3, "address": null, "x": 0, "y": 338, "width": 400, "height": 250 }, + { "number": 4, "address": null, "x": 400, "y": 338, "width": 400, "height": 250 }, + { "number": 5, "address": null, "x": 800, "y": 338, "width": 400, "height": 250 } + ] +} diff --git a/toolchain/macos/blueberry-wallpaper/Sources/main.swift b/toolchain/macos/blueberry-wallpaper/Sources/main.swift index a1e3db858e..dbd6c0f0df 100644 --- a/toolchain/macos/blueberry-wallpaper/Sources/main.swift +++ b/toolchain/macos/blueberry-wallpaper/Sources/main.swift @@ -112,6 +112,18 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { "computer.aesthetic.slab.desktop-tint.changed") private var statusTint: NSColor? + // ── beat ────────────────────────────────────────────────────────────── + // A listener (beat-listener.swift, or anything else that hears the room) + // publishes {bpm, beatAt, energy, updatedAt} as beat.json plus a + // distributed notification; the marks swell on each beat and settle back + // when the beat goes quiet. Nothing listens here; this only follows. + private static let beatFile = NSString( + string: "~/.local/share/slab/wallpaper/beat.json").expandingTildeInPath + private static let beatNote = Notification.Name("computer.aesthetic.slab.beat.changed") + private struct Beat { let bpm: Double; let beatAt: Double; let energy: Double; let updatedAt: Double } + private var beat: Beat? + private var pulse: CGFloat = 0 + override init(frame: NSRect) { super.init(frame: frame) wantsLayer = true @@ -141,6 +153,10 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { DistributedNotificationCenter.default().addObserver( self, selector: #selector(tintDidChange(_:)), name: Self.tintNote, object: nil) + beat = Self.readBeatFile() + DistributedNotificationCenter.default().addObserver( + self, selector: #selector(beatDidChange(_:)), + name: Self.beatNote, object: nil) loadModel() buildCameraAndLights() @@ -184,6 +200,40 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { applyAccentColor(animated: true) } + @objc private func beatDidChange(_ note: Notification) { + beat = Self.beat(from: note.userInfo) ?? Self.readBeatFile() + } + + private static func readBeatFile() -> Beat? { + guard let data = FileManager.default.contents(atPath: beatFile), + let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + else { return nil } + return beat(from: obj) + } + + private static func beat(from info: [AnyHashable: Any]?) -> Beat? { + guard let bpm = (info?["bpm"] as? NSNumber)?.doubleValue, bpm > 30, bpm < 300, + let beatAt = (info?["beatAt"] as? NSNumber)?.doubleValue + else { return nil } + let energy = (info?["energy"] as? NSNumber)?.doubleValue ?? 1 + let updatedAt = (info?["updatedAt"] as? NSNumber)?.doubleValue ?? beatAt + return Beat(bpm: bpm, beatAt: beatAt, energy: min(max(energy, 0), 1), updatedAt: updatedAt) + } + + /// 0 when the room is quiet; otherwise a swell that peaks on each beat and + /// decays through the bar. Smoothed so a dropped frame never snaps a mark. + private func beatPulse(now: Double) -> CGFloat { + var target: CGFloat = 0 + if let beat, now - beat.updatedAt < 4 { + let period = 60 / beat.bpm + var phase = (now - beat.beatAt).truncatingRemainder(dividingBy: period) / period + if phase < 0 { phase += 1 } + target = CGFloat(exp(-phase * 6) * beat.energy) * 0.38 + } + pulse += (target - pulse) * (target > pulse ? 0.55 : 0.12) + return pulse + } + @objc private func tintDidChange(_ note: Notification) { statusTint = Self.color(from: note.userInfo) ?? Self.readTintFile() updateAppearance(animated: true) @@ -290,9 +340,6 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { prototype.addChildNode(child) } applyOriginalGLBMaterials(to: prototype) - if Emblem.isModel, Emblem.scale != 1 { - prototype.scale = SCNVector3(Emblem.scale, Emblem.scale, Emblem.scale) - } } let (lo, hi) = prototype.boundingBox modelWidth = max(CGFloat(hi.x - lo.x), 0.001) @@ -326,11 +373,14 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { // field reads full-bleed instead of as an inset/letterboxed stage. let padding: CGFloat = 0.13 var candidates: [Placement] = [] + let swell = 1 + beatPulse(now: Date().timeIntervalSince1970) for (model, spec) in zip(models, Self.specs) { let raw = CGFloat(elapsed / spec.riseSeconds) + spec.phase let progress = raw - floor(raw) let scale = spec.scale * Self.markScale + let shown = scale * swell * (Emblem.isModel ? Emblem.scale : 1) + model.scale = SCNVector3(shown, shown, shown) let width = modelWidth * scale * 1.08 let height = modelHeight * scale * 1.08 // Use the full rotating footprint, not just a fraction of model diff --git a/toolchain/macos/blueberry-wallpaper/beat-listener.swift b/toolchain/macos/blueberry-wallpaper/beat-listener.swift new file mode 100644 index 0000000000..957af7ab1e --- /dev/null +++ b/toolchain/macos/blueberry-wallpaper/beat-listener.swift @@ -0,0 +1,162 @@ +// beat-listener — hear the room, publish its beat for the wallpaper to follow. +// +// swiftc -O -framework AVFoundation -framework Accelerate beat-listener.swift -o beat-listener +// ./beat-listener [--device ] [--quiet] +// +// Opens the default (or named) audio input, tracks an onset-strength envelope +// at ~86 Hz, estimates tempo by autocorrelation over the last eight seconds +// (60–180 BPM), and twice a second writes ~/.local/share/slab/wallpaper/beat.json +// {bpm, beatAt, energy, updatedAt} and posts +// computer.aesthetic.slab.beat.changed with the same payload. When the room is +// quiet it publishes energy 0 so followers settle. Audio never leaves memory. +import AVFoundation +import Accelerate +import Foundation + +let args = CommandLine.arguments +func flag(_ k: String) -> String? { if let i = args.firstIndex(of: k), i + 1 < args.count { return args[i + 1] }; return nil } +let quiet = args.contains("--quiet") +let outPath = NSString(string: "~/.local/share/slab/wallpaper/beat.json").expandingTildeInPath +let note = Notification.Name("computer.aesthetic.slab.beat.changed") + +let engine = AVAudioEngine() +if let wanted = flag("--device") { + // Pick an input by name via Core Audio so a USB mic can be preferred. + var address = AudioObjectPropertyAddress(mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var size: UInt32 = 0 + AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size) + var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout.size) + AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &ids) + for id in ids { + var nameAddress = AudioObjectPropertyAddress(mSelector: kAudioObjectPropertyName, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var name: CFString = "" as CFString + var nameSize = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(id, &nameAddress, 0, nil, &nameSize, &name) == noErr else { continue } + if (name as String).localizedCaseInsensitiveContains(wanted) { + var deviceID = id + if let unit = engine.inputNode.audioUnit { + AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice, + kAudioUnitScope_Global, 0, &deviceID, UInt32(MemoryLayout.size)) + } + if !quiet { print("input: \(name)") } + break + } + } +} + +let input = engine.inputNode +let format = input.outputFormat(forBus: 0) +guard format.channelCount > 0 else { fputs("no audio input device on this machine\n", stderr); exit(1) } +let hop = 512 +let hopRate = format.sampleRate / Double(hop) // ~86–94 envelopes per second +let window = Int(hopRate * 8) // eight seconds of onset history +var onsets = [Float](repeating: 0, count: window) +var head = 0 +var previousEnergy: Float = 0 +var smoothedEnergy: Float = 0 +var loudness: Float = 0 +var lastOnsetTime = Date().timeIntervalSince1970 +var lastOnsetStrength: Float = 0 +let lock = NSLock() + +input.installTap(onBus: 0, bufferSize: AVAudioFrameCount(hop * 4), format: format) { buffer, _ in + guard let data = buffer.floatChannelData?[0] else { return } + let n = Int(buffer.frameLength) + var offset = 0 + lock.lock(); defer { lock.unlock() } + while offset + hop <= n { + var rms: Float = 0 + vDSP_rmsqv(data + offset, 1, &rms, vDSP_Length(hop)) + offset += hop + // Onset strength: the rise of a lightly smoothed energy envelope. + smoothedEnergy += (rms - smoothedEnergy) * 0.5 + let rise = max(smoothedEnergy - previousEnergy, 0) + previousEnergy = smoothedEnergy + loudness += (rms - loudness) * 0.02 + onsets[head] = rise + head = (head + 1) % window + // A beat candidate: a rise well above the recent mean rise. + var mean: Float = 0 + vDSP_meanv(onsets, 1, &mean, vDSP_Length(window)) + if rise > mean * 3, rise > lastOnsetStrength * 0.6 || Date().timeIntervalSince1970 - lastOnsetTime > 0.25 { + lastOnsetTime = Date().timeIntervalSince1970 + lastOnsetStrength = rise + } + lastOnsetStrength *= 0.995 + } +} + +/// Tempo from the autocorrelation of the onset envelope, 60–180 BPM. +func estimateBPM() -> (bpm: Double, confidence: Double) { + let x = onsets + var mean: Float = 0 + vDSP_meanv(x, 1, &mean, vDSP_Length(window)) + var centered = [Float](repeating: 0, count: window) + var negMean = -mean + vDSP_vsadd(x, 1, &negMean, ¢ered, 1, vDSP_Length(window)) + var zero: Float = 0 + vDSP_dotpr(centered, 1, centered, 1, &zero, vDSP_Length(window)) + guard zero > 0 else { return (0, 0) } + let minLag = Int(hopRate * 60 / 180), maxLag = Int(hopRate * 60 / 60) + var ac = [Float](repeating: 0, count: maxLag + 2) + for lag in (minLag - 1)...(maxLag + 1) where lag > 0 { + var r: Float = 0 + centered.withUnsafeBufferPointer { p in + vDSP_dotpr(p.baseAddress!, 1, p.baseAddress! + lag, 1, &r, vDSP_Length(window - lag)) + } + ac[lag] = r / Float(window - lag) + } + var best = (lag: 0, value: Float(0)) + for lag in minLag...maxLag { + // A faint preference for faster lags breaks exact octave ties only. + let weighted = ac[lag] * (1 + 0.02 * Float(maxLag - lag) / Float(maxLag)) + if weighted > best.value { best = (lag, weighted) } + } + guard best.lag > 0 else { return (0, 0) } + // Parabolic interpolation between neighbouring lags: the envelope rate is + // coarse (~90–190 Hz), so the integer lag alone quantises the tempo. + let l = best.lag, y0 = ac[l - 1], y1 = ac[l], y2 = ac[l + 1] + let denom = y0 - 2 * y1 + y2 + let shift = denom != 0 ? max(-0.5, min(0.5, 0.5 * (y0 - y2) / denom)) : 0 + let lag = Double(l) + Double(shift) + let confidence = Double(ac[l] / (zero / Float(window))) + return (60 * hopRate / lag, min(max(confidence, 0), 1)) +} + +func publish() { + lock.lock() + let (bpm, confidence) = estimateBPM() + let energy = min(Double(loudness) * 12, 1) + let beatAt = lastOnsetTime + lock.unlock() + let live = energy > 0.04 && confidence > 0.08 && bpm > 0 + let payload: [String: Any] = [ + "bpm": live ? (bpm * 10).rounded() / 10 : 0, + "beatAt": beatAt, + "energy": live ? (energy * 100).rounded() / 100 : 0, + "confidence": (confidence * 100).rounded() / 100, + "updatedAt": Date().timeIntervalSince1970, + ] + try? FileManager.default.createDirectory(atPath: (outPath as NSString).deletingLastPathComponent, + withIntermediateDirectories: true) + if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) { + try? data.write(to: URL(fileURLWithPath: outPath), options: .atomic) + } + DistributedNotificationCenter.default().postNotificationName( + note, object: nil, userInfo: payload, deliverImmediately: true) + if !quiet { + print(String(format: "bpm %5.1f energy %.2f confidence %.2f%@", payload["bpm"] as! Double, + payload["energy"] as! Double, confidence, live ? "" : " (quiet)")) + fflush(stdout) + } +} + +do { try engine.start() } catch { fputs("could not start audio input: \(error)\n", stderr); exit(1) } +if !quiet { print("listening at \(Int(format.sampleRate)) Hz · publishing \(outPath)") } +let timer = Timer(timeInterval: 0.5, repeats: true) { _ in publish() } +RunLoop.main.add(timer, forMode: .common) +RunLoop.main.run() diff --git a/toolchain/macos/displays.swift b/toolchain/macos/displays.swift new file mode 100644 index 0000000000..cc9a747c3b --- /dev/null +++ b/toolchain/macos/displays.swift @@ -0,0 +1,243 @@ +// Native display inventory, identification, and session layout transactions. +import AppKit +import CoreGraphics +import Foundation +import Darwin +import SystemConfiguration + +struct DisplayError: Error, CustomStringConvertible { + let description: String + init(_ message: String) { description = message } +} +func check(_ result: CGError) throws { + if result != .success { throw DisplayError("CoreGraphics error \(result.rawValue)") } +} +let encoder = JSONEncoder() +encoder.outputFormatting = [.sortedKeys] +func output(_ value: T) throws { + FileHandle.standardOutput.write(try encoder.encode(value)) + FileHandle.standardOutput.write(Data("\n".utf8)) +} +struct Rect: Codable, Equatable { + var x: Int; var y: Int; var width: Int; var height: Int + init(_ r: CGRect) { x = Int(r.minX); y = Int(r.minY); width = Int(r.width); height = Int(r.height) } +} +struct Mode: Codable, Equatable { + let id: Int32; let width: Int; let height: Int + let pixelWidth: Int; let pixelHeight: Int; let hz: Double + init(_ m: CGDisplayMode) { + id = m.ioDisplayModeID; width = m.width; height = m.height + pixelWidth = m.pixelWidth; pixelHeight = m.pixelHeight; hz = m.refreshRate + } +} +struct Display: Codable { + let number: Int; let uuid: String; let id: UInt32; let name: String + let builtin: Bool; let main: Bool; let active: Bool; let asleep: Bool + let mirrored: Bool; let rotation: Double; let bounds: Rect + let millimeters: [String: Double]; let mode: Mode? +} +struct Inventory: Encodable { + let version = 1 + let coordinateSystem = "quartz-top-left-points" + let displays: [Display] +} +let stateDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/slab/displays") + +func onlineIDs() throws -> [CGDirectDisplayID] { + var count: UInt32 = 0 + try check(CGGetOnlineDisplayList(0, nil, &count)) + var ids = [CGDirectDisplayID](repeating: 0, count: Int(count)) + if count > 0 { try check(CGGetOnlineDisplayList(count, &ids, &count)) } + return Array(ids.prefix(Int(count))) +} +func uuidFor(_ id: CGDirectDisplayID) throws -> String { + guard let uuid = CGDisplayCreateUUIDFromDisplayID(id)?.takeRetainedValue() else { + throw DisplayError("No persistent UUID for display \(id); retry after the display settles") + } + return CFUUIDCreateString(nil, uuid) as String +} +// Numbers belong to this host and are never recycled when a panel disconnects. +// A lock and atomic write keep simultaneous CLI/MCP calls from renumbering it. +func numbersFor(_ uuids: [String]) throws -> [String: Int] { + try FileManager.default.createDirectory(at: stateDir, withIntermediateDirectories: true) + let fd = open(stateDir.appendingPathComponent("numbers.lock").path, O_CREAT | O_RDWR, 0o600) + guard fd >= 0 else { throw DisplayError("Cannot open display number lock") } + defer { close(fd) } + guard flock(fd, LOCK_EX) == 0 else { throw DisplayError("Cannot lock display numbers") } + defer { flock(fd, LOCK_UN) } + let path = stateDir.appendingPathComponent("numbers.json") + var numbers: [String: Int] = [:] + if FileManager.default.fileExists(atPath: path.path) { + numbers = try JSONDecoder().decode([String: Int].self, from: Data(contentsOf: path)) + guard numbers.values.allSatisfy({ $0 > 0 }), Set(numbers.values).count == numbers.count else { + throw DisplayError("Invalid numbers.json; refusing to renumber displays") + } + } + var changed = false + for uuid in uuids where numbers[uuid] == nil { + numbers[uuid] = (numbers.values.max() ?? 0) + 1; changed = true + } + if changed { try encoder.encode(numbers).write(to: path, options: .atomic) } + return numbers +} +func inventory() throws -> Inventory { + let ids = try onlineIDs().sorted { + if CGDisplayIsBuiltin($0) != CGDisplayIsBuiltin($1) { return CGDisplayIsBuiltin($0) != 0 } + return $0 < $1 + } + let uuids = try ids.map(uuidFor) + guard Set(uuids).count == uuids.count else { throw DisplayError("Duplicate display UUIDs; cannot safely address these panels") } + let numbers = try numbersFor(uuids) + let screens = NSScreen.screens + return Inventory(displays: try ids.map { id in + let uuid = try uuidFor(id) + let screen = screens.first { ($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value == id } + let mm = CGDisplayScreenSize(id) + return Display(number: numbers[uuid]!, uuid: uuid, id: id, + name: screen?.localizedName ?? "Display", builtin: CGDisplayIsBuiltin(id) != 0, + main: CGDisplayIsMain(id) != 0, active: CGDisplayIsActive(id) != 0, + asleep: CGDisplayIsAsleep(id) != 0, mirrored: CGDisplayIsInMirrorSet(id) != 0, + rotation: CGDisplayRotation(id), bounds: Rect(CGDisplayBounds(id)), + millimeters: ["width": mm.width, "height": mm.height], + mode: CGDisplayCopyDisplayMode(id).map(Mode.init)) + }.sorted { $0.number < $1.number }) +} +func modesFor(_ id: CGDirectDisplayID) -> [CGDisplayMode] { + CGDisplayCopyAllDisplayModes(id, [kCGDisplayShowDuplicateLowResolutionModes: true] as CFDictionary) as? [CGDisplayMode] ?? [] +} +struct Placement: Codable, Equatable { + let uuid: String; let x: Int32; let y: Int32; let modeID: Int32; let rotation: Double +} +func placements(_ inv: Inventory) throws -> [Placement] { + try inv.displays.filter { $0.active }.map { + guard let mode = $0.mode else { throw DisplayError("Missing display mode") } + return Placement(uuid: $0.uuid, x: Int32($0.bounds.x), y: Int32($0.bounds.y), modeID: mode.id, rotation: $0.rotation) + }.sorted { $0.uuid < $1.uuid } +} +struct Request: Codable { let expected: [Placement]; let layout: [Placement] } +func apply(_ request: Request) throws { + try FileManager.default.createDirectory(at: stateDir, withIntermediateDirectories: true) + let lock = open(stateDir.appendingPathComponent("layout.lock").path, O_CREAT | O_RDWR, 0o600) + guard lock >= 0 else { throw DisplayError("Cannot open layout lock") } + defer { close(lock) } + guard flock(lock, LOCK_EX) == 0 else { throw DisplayError("Cannot lock display layout") } + defer { flock(lock, LOCK_UN) } + let before = try inventory() + guard !before.displays.contains(where: { $0.mirrored }) else { throw DisplayError("Mirrored layouts are read-only") } + let current = try placements(before) + guard current == request.expected.sorted(by: { $0.uuid < $1.uuid }) else { + throw DisplayError("Displays changed since preview; read the geometry again") + } + guard request.layout.count == current.count, + Set(request.layout.map { $0.uuid }) == Set(current.map { $0.uuid }) else { + throw DisplayError("Layout must contain every active display exactly once") + } + var work: [(Display, Placement, CGDisplayMode)] = [] + var rects: [CGRect] = [] + for p in request.layout { + guard let d = before.displays.first(where: { $0.uuid == p.uuid }), p.rotation == d.rotation, + let mode = modesFor(d.id).first(where: { $0.ioDisplayModeID == p.modeID }) else { + throw DisplayError("Unavailable mode or rotation change; list modes again") + } + guard abs(Int64(p.x)) <= 100000, abs(Int64(p.y)) <= 100000 else { throw DisplayError("Origin outside supported range") } + work.append((d, p, mode)) + let portrait = Int(d.rotation) % 180 != 0 + rects.append(CGRect(x: Int(p.x), y: Int(p.y), width: portrait ? mode.height : mode.width, height: portrait ? mode.width : mode.height)) + } + guard rects.contains(where: { $0.origin == .zero }) else { throw DisplayError("One display must start at (0,0)") } + for i in rects.indices { + for j in rects.indices where j > i { + let intersection = rects[i].intersection(rects[j]) + if !intersection.isNull && intersection.width > 0 && intersection.height > 0 { throw DisplayError("Displays overlap") } + } + } + func touches(_ a: CGRect, _ b: CGRect) -> Bool { + ((a.maxX == b.minX || b.maxX == a.minX) && min(a.maxY, b.maxY) > max(a.minY, b.minY)) || + ((a.maxY == b.minY || b.maxY == a.minY) && min(a.maxX, b.maxX) > max(a.minX, b.minX)) + } + var reached: Set = rects.isEmpty ? [] : [0] + while true { + let old = reached + for i in rects.indices where !reached.contains(i) { + if reached.contains(where: { touches(rects[i], rects[$0]) }) { reached.insert(i) } + } + if reached == old { break } + } + guard reached.count == rects.count else { throw DisplayError("Displays must share edges; gaps cannot be applied exactly") } + let backup = stateDir.appendingPathComponent("layout-\(UUID().uuidString).json") + try encoder.encode(current).write(to: backup, options: .atomic) + fputs("Restore file: \(backup.path)\n", stderr) + var config: CGDisplayConfigRef? + try check(CGBeginDisplayConfiguration(&config)) + guard let config else { throw DisplayError("Cannot begin display transaction") } + var completed = false + defer { if !completed { CGCancelDisplayConfiguration(config) } } + for (d, p, mode) in work { + if d.mode?.id != p.modeID { try check(CGConfigureDisplayWithDisplayMode(config, d.id, mode, nil)) } + try check(CGConfigureDisplayOrigin(config, d.id, p.x, p.y)) + } + try check(CGCompleteDisplayConfiguration(config, .forSession)); completed = true + // Quartz may normalize geometry; always return the observed result. + Thread.sleep(forTimeInterval: 0.5) + struct Result: Encodable { let backup: String; let matchesRequested: Bool; let inventory: Inventory } + let after = try inventory() + try output(Result(backup: backup.path, + matchesRequested: try placements(after) == request.layout.sorted { $0.uuid < $1.uuid }, inventory: after)) +} + +var labelWindows: [NSWindow] = [] +func identify(_ inv: Inventory, seconds: Double, number: Int?, seatNumber: Int? = nil) throws { + let app = NSApplication.shared + app.setActivationPolicy(.accessory) + let host = SCDynamicStoreCopyLocalHostName(nil) as String? ?? ProcessInfo.processInfo.hostName.components(separatedBy: ".")[0] + for d in inv.displays where number == nil || number == d.number { + guard let screen = NSScreen.screens.first(where: { ($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value == d.id }) else { continue } + let width = min(520.0, screen.frame.width * 0.7), height = min(240.0, screen.frame.height * 0.5) + let rect = NSRect(x: screen.frame.midX - width / 2, y: screen.frame.midY - height / 2, width: width, height: height) + let window = NSWindow(contentRect: rect, styleMask: [.borderless], backing: .buffered, defer: false) + window.level = .screenSaver; window.isOpaque = true; window.backgroundColor = NSColor(calibratedWhite: 0.08, alpha: 1) + window.ignoresMouseEvents = true; window.hasShadow = true + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + let numberLabel = NSTextField(labelWithString: "\(seatNumber ?? d.number)") + numberLabel.font = .monospacedDigitSystemFont(ofSize: 132, weight: .bold) + numberLabel.textColor = .white; numberLabel.alignment = .center + numberLabel.frame = NSRect(x: 0, y: 52, width: width, height: 168) + let hostLabel = NSTextField(labelWithString: seatNumber == nil ? host : "\(host):\(d.number)") + hostLabel.font = .systemFont(ofSize: 30, weight: .medium); hostLabel.textColor = .white; hostLabel.alignment = .center + hostLabel.frame = NSRect(x: 0, y: 16, width: width, height: 40) + window.contentView?.addSubview(numberLabel); window.contentView?.addSubview(hostLabel) + window.orderFrontRegardless(); labelWindows.append(window) + } + guard !labelWindows.isEmpty else { throw DisplayError("No drawable display for that number") } + Timer.scheduledTimer(withTimeInterval: seconds, repeats: false) { _ in + labelWindows.forEach { $0.orderOut(nil) }; app.stop(nil) + app.postEvent(NSEvent.otherEvent(with: .applicationDefined, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, subtype: 0, data1: 0, data2: 0)!, atStart: false) + } + app.run() + try output(["identified": labelWindows.count]) +} + +do { + let args = Array(CommandLine.arguments.dropFirst()) + switch args.first ?? "list" { + case "list": try output(inventory()) + case "modes": + guard args.count == 2, let number = Int(args[1]), let d = try inventory().displays.first(where: { $0.number == number }) else { throw DisplayError("usage: modes NUMBER") } + try output(modesFor(d.id).map(Mode.init)) + case "identify": + guard args.count >= 2, args.count <= 4, let seconds = Double(args[1]), seconds.isFinite, seconds >= 1, seconds <= 30 else { throw DisplayError("usage: identify SECONDS [NUMBER [SEAT_NUMBER]] (1–30 seconds)") } + let number = args.count >= 3 ? Int(args[2]) : nil + let seatNumber = args.count == 4 ? Int(args[3]) : nil + if args.count >= 3 && (number == nil || number! < 1) { throw DisplayError("Invalid display number") } + if args.count == 4 && (seatNumber == nil || seatNumber! < 1) { throw DisplayError("Invalid seat number") } + try identify(inventory(), seconds: seconds, number: number, seatNumber: seatNumber) + case "apply": + try apply(JSONDecoder().decode(Request.self, from: FileHandle.standardInput.readDataToEndOfFile())) + case "saved": + guard args.count == 2, args[1].range(of: "^layout-[A-Fa-f0-9-]{36}\\.json$", options: .regularExpression) != nil else { throw DisplayError("Invalid backup name") } + try output(JSONDecoder().decode([Placement].self, from: Data(contentsOf: stateDir.appendingPathComponent(args[1])))) + default: throw DisplayError("usage: slab-displays-native list | modes NUMBER | identify SECONDS [NUMBER] | apply < request.json") + } +} catch { + fputs("displays: \(error)\n", stderr); exit(1) +} diff --git a/toolchain/macos/install-displays.sh b/toolchain/macos/install-displays.sh new file mode 100644 index 0000000000..80f2496ca8 --- /dev/null +++ b/toolchain/macos/install-displays.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +repo=$(cd "$(dirname "$0")/../.." && pwd) +mkdir -p "$HOME/.local/bin" +build=$(mktemp -d "${TMPDIR:-/tmp}/slab-displays.XXXXXX") +trap 'rm -rf "$build"' EXIT +nice -n 10 xcrun swiftc -O "$repo/toolchain/macos/displays.swift" -o "$build/slab-displays-native" +install -m 755 "$build/slab-displays-native" "$HOME/.local/bin/slab-displays-native" +# A wrapper preserves import.meta.url's real entry point when called from PATH. +node_bin=$(node -p 'require("node:fs").realpathSync(process.execPath)') +printf '#!/usr/bin/env bash\nexec %q %q "$@"\n' "$node_bin" "$repo/toolchain/fleet/displays.mjs" > "$HOME/.local/bin/displays" +chmod 755 "$HOME/.local/bin/displays" +echo 'Installed displays and slab-displays-native' diff --git a/toolchain/youtube/icp-email-poster.mjs b/toolchain/youtube/icp-email-poster.mjs new file mode 100644 index 0000000000..ad0f82c6f1 --- /dev/null +++ b/toolchain/youtube/icp-email-poster.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// Make a looping email poster from two reviewed product images. No generated media. +// node icp-email-poster.mjs comparison.json output.gif +// comparison.json: {panels:[{file,crop?:[x,y,width,height]}, {file,crop?:[x,y,width,height]}]} +// Paths are relative to the manifest. Crops must preserve the entire product. +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { readFileSync, writeFileSync, mkdtempSync, renameSync, rmSync, mkdirSync } from 'node:fs'; +import { dirname, resolve, join } from 'node:path'; + +const [manifestArg, outputArg] = process.argv.slice(2); +if (!manifestArg || !outputArg) { + console.error('Usage: node icp-email-poster.mjs comparison.json output.gif'); + process.exit(1); +} +const manifest = resolve(manifestArg), output = resolve(outputArg); +const spec = JSON.parse(readFileSync(manifest, 'utf8')); +if (!output.endsWith('.gif') || spec.panels?.length !== 2) throw Error('Expected two panels and a .gif output.'); +const hash = b => createHash('sha256').update(b).digest('hex'); +const panels = spec.panels.map(p => { + if (typeof p.file !== 'string') throw Error('Each panel needs an image file.'); + if (p.crop && (p.crop.length !== 4 || p.crop.some(n => !Number.isInteger(n) || n < 0) || p.crop[2] === 0 || p.crop[3] === 0)) { + throw Error('Crop must be [x, y, positive width, positive height].'); + } + const file = resolve(dirname(manifest), p.file); + return { ...p, file, sha256: hash(readFileSync(file)) }; +}); +mkdirSync(dirname(output), { recursive: true }); +const work = mkdtempSync(join(dirname(output), '.poster-')); +const run = (command, args) => { + const r = spawnSync(command, args, { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }); + if (r.error) throw r.error; + if (r.status !== 0) throw Error(`${command} failed: ${r.stderr}`); + return r.stdout; +}; +// Use PATH so the fleet's utility-priority ffmpeg shim remains in effect. +const ff = ['-hide_banner', '-loglevel', 'error', '-y', '-threads', '2', '-filter_complex_threads', '2']; +const fit = (p, i) => { + const crop = p.crop ? `crop=${p.crop[2]}:${p.crop[3]}:${p.crop[0]}:${p.crop[1]},` : ''; + return `[${i}:v]${crop}scale=384:468:force_original_aspect_ratio=decrease:flags=lanczos,setsar=1,pad=384:468:(ow-iw)/2:(oh-ih)/2:color=white,format=rgba[p${i}]`; +}; +try { + const still = join(work, 'comparison.png'), shine = join(work, 'shine.png'), gif = join(work, 'poster.gif'); + run('ffmpeg', [...ff, '-i', panels[0].file, '-i', panels[1].file, + '-filter_complex', `${panels.map(fit).join(';')};[p0]pad=400:468:0:0:color=white[left];[left][p1]hstack=inputs=2,pad=800:500:8:16:color=white[out]`, + '-map', '[out]', '-frames:v', '1', still]); + // A low-opacity highlight passes once; the first and last frames are the same + // complete comparison. No crossfade, camera move, caption, or sign-off frame. + run('ffmpeg', [...ff, '-f', 'lavfi', '-i', + "nullsrc=s=160x1000,format=rgba,geq=r=255:g=255:b=255:a='22*exp(-pow((X-80)/24,2))'", + '-vf', 'rotate=0.28:ow=rotw(0.28):oh=roth(0.28):fillcolor=black@0,format=rgba', '-frames:v', '1', shine]); + run('ffmpeg', [...ff, '-loop', '1', '-framerate', '12', '-t', '4', '-i', still, '-loop', '1', '-framerate', '12', '-t', '4', '-i', shine, + '-filter_complex', "[0][1]overlay=x='-600+1600*(t-1)/1.5':y=-250:enable='between(t,1,2.5)':format=auto,fps=12,split[a][b];[a]palettegen=max_colors=192[p];[b][p]paletteuse=dither=bayer:bayer_scale=4[out]", + '-map', '[out]', '-frames:v', '48', '-loop', '0', gif]); + const probe = JSON.parse(run('ffprobe', ['-v', 'error', '-show_entries', 'format=duration:stream=width,height,nb_frames', '-of', 'json', gif])); + if (probe.streams[0]?.width !== 800 || probe.streams[0]?.height !== 500 || Math.abs(Number(probe.format.duration) - 4) > .02) { + throw Error('Poster dimensions or duration failed verification.'); + } + const bytes = readFileSync(gif), receipt = { createdAt: new Date().toISOString(), panels, width: 800, height: 500, + duration: Number(probe.format.duration), frames: Number(probe.streams[0].nb_frames), bytes: bytes.length, sha256: hash(bytes) }; + renameSync(still, output.replace(/\.gif$/, '.png')); + renameSync(gif, output); + writeFileSync(output.replace(/\.gif$/, '.json'), JSON.stringify(receipt, null, 2) + '\n'); + console.log(`${output}: 800×500, ${receipt.duration}s, ${receipt.frames} frames, ${receipt.bytes} bytes`); +} finally { + rmSync(work, { recursive: true, force: true }); +} diff --git a/toolchain/youtube/icp-review-playlists.sh b/toolchain/youtube/icp-review-playlists.sh new file mode 100755 index 0000000000..8ff28fb563 --- /dev/null +++ b/toolchain/youtube/icp-review-playlists.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# icp-review-playlists.sh — runs ON the render host (where the cuts live): +# one UNLISTED playlist per client, every voice cut uploaded UNLISTED into it, +# then prints the playlist and video URLs. Refuses if any receipt is not unlisted. +# +# icp-review-playlists.sh +# plan-file lines: playlist| — (voice A/B) +# video||<absolute path to .mp4> +# A blank line or '#' is ignored. Videos attach to the most recent playlist line. +set -euo pipefail +CH=${1:?channel}; PLAN=${2:?plan file} +export PATH=/opt/homebrew/bin:$PATH +export YT_CLIENT_JSON=${YT_CLIENT_JSON:-$HOME/.config/yt/client.json} +export YT_TOKEN_JSON=${YT_TOKEN_JSON:-$HOME/.config/yt/$CH-token.json} +YT="node $HOME/.local/share/yt/toolchain/youtube/yt.mjs" +OUT=${OUT:-$HOME/.local/share/yt/icp-review-urls.md}; : > "$OUT" +PL="" +while IFS= read -r line; do + [[ -z "$line" || "$line" == \#* ]] && continue + kind=${line%%|*}; rest=${line#*|} + if [[ $kind == playlist ]]; then + PL=$($YT playlist-ensure --as "$CH" --title "Fuser ICP review · $rest" --privacy unlisted --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s.trim().split("\n").pop());if(j.privacy!=="unlisted"){console.error("PLAYLIST NOT UNLISTED");process.exit(2)}console.log(j.id)})') + printf "\n## %s\nplaylist: https://www.youtube.com/playlist?list=%s\n" "$rest" "$PL" >> "$OUT" + elif [[ $kind == video ]]; then + title=${rest%%|*}; file=${rest#*|} + [[ -f $file ]] || { echo "skip (missing): $file" >&2; continue; } + sidecar="${file%.*}.youtube.json" # yt.mjs writes <name>.youtube.json beside the video + if [[ -f $sidecar ]]; then echo "already uploaded (receipt exists): $(basename "$file")" >&2 + else + $YT upload "$file" --as "$CH" --title "$title" --privacy unlisted --category 28 --playlist "$PL" \ + --description "Fuser ICP review cut (unlisted). Voice A/B. Not for distribution." >/dev/null + fi + url=$(node -e 'const r=require(process.argv[1]);if(r.privacy!=="unlisted"){console.error("NOT UNLISTED",r.videoId);process.exit(2)}console.log(r.watchUrl||("https://youtu.be/"+r.videoId))' "$sidecar") + echo "- $title — $url" >> "$OUT" + fi +done < "$PLAN" +cat "$OUT" diff --git a/toolchain/youtube/mini-setup.sh b/toolchain/youtube/mini-setup.sh new file mode 100755 index 0000000000..1bbe74d2a5 --- /dev/null +++ b/toolchain/youtube/mini-setup.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# mini-setup.sh — put the YouTube CLI on a fleet Mac and sign in there, so a +# client channel's token never lives on the author's laptop. +# +# toolchain/youtube/mini-setup.sh <ssh-host> <channel-name> +# e.g. toolchain/youtube/mini-setup.sh chicken fuser +# +# What it does: copies yt.mjs + the OAuth client (from the vault) to the host +# under ~/.local/share/yt and ~/.config/yt (mode 600), then runs `yt.mjs auth` +# THERE. The consent URL is printed; open it in a browser ON THAT MACHINE (the +# callback listens on its localhost) and sign in as the channel's Google +# account. The refresh token is saved on the host as ~/.config/yt/<channel>-token.json. +# Afterwards every yt.mjs command on that host needs: +# YT_CLIENT_JSON=~/.config/yt/client.json YT_TOKEN_JSON=~/.config/yt/<channel>-token.json +set -euo pipefail +HOST=${1:?ssh host}; CH=${2:?channel name} +cd "$(dirname "$0")/../.." +CLIENT=aesthetic-computer-vault/youtube/client.json +[[ -f $CLIENT ]] || { echo "missing $CLIENT (decrypt client.json.gpg first)" >&2; exit 2; } +ssh "$HOST" 'mkdir -p ~/.local/share/yt/toolchain/youtube ~/.config/yt && chmod 700 ~/.config/yt' +scp -q toolchain/youtube/yt.mjs "$HOST":~/.local/share/yt/toolchain/youtube/yt.mjs +scp -q "$CLIENT" "$HOST":~/.config/yt/client.json +ssh "$HOST" 'chmod 600 ~/.config/yt/client.json' +echo "→ starting consent flow on $HOST; a browser window should open there (or open the printed URL on $HOST)." +ssh "$HOST" "export PATH=/opt/homebrew/bin:\$PATH YT_CLIENT_JSON=~/.config/yt/client.json YT_TOKEN_JSON=~/.config/yt/$CH-token.json; cd ~/.local/share/yt && node toolchain/youtube/yt.mjs auth --as $CH && node toolchain/youtube/yt.mjs whoami --as $CH"