diff --git a/docs/embedding-ac-pieces.md b/docs/embedding-ac-pieces.md index 6653021ffa..3bc7d564f0 100644 --- a/docs/embedding-ac-pieces.md +++ b/docs/embedding-ac-pieces.md @@ -135,3 +135,20 @@ the class and edge-to-edge bounds must survive both. that have no normal query string. BIOS honors it on every reframe, including explicit nonzero gap requests. It is not a URL parameter, and an ordinary web embed should use `nogap=true` rather than depend on script injection. + +## Iterate on Walkieware layout without restarting + +For a running Walkieware app with the live-layout bridge, send a CSS file through +its existing owner-authenticated thread socket: + +```sh +node slab/bin/ww.mjs layout wwRuboh /tmp/walkieware-layout.css +``` + +The stylesheet replaces the previous live override and persists locally across +launches. An empty file clears it. This updates the surrounding app shell, not +the cross-origin AC document. It does not reload the WebView, regenerate the +piece, or add a piece version. The device must be online and idle; the command +returns an acknowledgement after applying the override. CSS is limited to 100 KB. +Native Swift changes still require an app build/install; this is a CSS iteration +path, not Swift code hot reload. diff --git a/lith/walkieware-socket.mjs b/lith/walkieware-socket.mjs index c62a805544..46c504af3c 100644 --- a/lith/walkieware-socket.mjs +++ b/lith/walkieware-socket.mjs @@ -55,10 +55,11 @@ export function attachWalkiewareSocket(server,{authenticate,store,authMs=5000,li const id=typeof m.id==='string'&&/^[a-zA-Z0-9-]{1,80}$/.test(m.id)?m.id:randomUUID(); if(!room.device){send(ws,{type:'result',id,ok:false,error:'Device offline; saved versions remain available'});return;} if(room.commands.size||room.state?.busy){send(ws,{type:'result',id,ok:false,error:'Device busy'});return;} - if(!['ask','undo','edit'].includes(m.action)||!Number.isSafeInteger(m.baseVersion)||typeof m.baseHash!=='string'||(m.action==='ask'&&(typeof m.text!=='string'||!m.text.trim()||m.text.length>20000))||(m.action==='edit'&&(typeof m.source!=='string'||Buffer.byteLength(m.source)>500000||!m.source.trim())))throw Error('Invalid command'); + if(m.action==='layout'&&(typeof m.css!=='string'||Buffer.byteLength(m.css)>100000))throw Error('Invalid layout'); + if(!['ask','undo','edit','layout'].includes(m.action)||!Number.isSafeInteger(m.baseVersion)||typeof m.baseHash!=='string'||(m.action==='ask'&&(typeof m.text!=='string'||!m.text.trim()||m.text.length>20000))||(m.action==='edit'&&(typeof m.source!=='string'||Buffer.byteLength(m.source)>500000||!m.source.trim())))throw Error('Invalid command'); const timer=setTimeout(()=>{room.commands.delete(id);send(ws,{type:'result',id,ok:false,error:'Timed out; inspect history before retrying'});},180000);timer.unref?.(); room.commands.set(id,{ws,timer}); - send(room.device,{type:'command',id,action:m.action,text:m.text,source:m.source,baseVersion:m.baseVersion,baseHash:m.baseHash}); + send(room.device,{type:'command',id,action:m.action,text:m.text,source:m.source,css:m.css,baseVersion:m.baseVersion,baseHash:m.baseHash}); send(ws,{type:'accepted',id});return; } if(m.type==='result'&&role==='device') { diff --git a/lith/walkieware-socket.test.mjs b/lith/walkieware-socket.test.mjs index 2de4490daf..ba89549968 100644 --- a/lith/walkieware-socket.test.mjs +++ b/lith/walkieware-socket.test.mjs @@ -47,6 +47,10 @@ test('real sockets isolate accounts, persist versions and relay checked commands const command=await device.next('command');assert.equal(command.text,'Make it 3D');await agent.next('accepted'); agent.send({type:'command',id:'edit-2',action:'undo',baseVersion:0,baseHash:'stale'});assert.equal((await agent.next('result')).error,'Device busy'); device.send({type:'result',id:'edit-1',ok:true,head:1});assert.equal((await agent.next('result')).ok,true); + agent.send({type:'command',id:'layout-1',action:'layout',css:'#live-work {padding: 12px}',baseVersion:0,baseHash:sourceHash(ledger.versions[0].source)}); + const layout=await device.next('command');assert.equal(layout.css,'#live-work {padding: 12px}');await agent.next('accepted'); + device.send({type:'result',id:'layout-1',ok:true,head:0});assert.equal((await agent.next('result')).head,0); + assert.equal((await f.store.read('owner','wwRuboh')).ledger.versions.length,1,'layout does not create piece history'); agent.ws.close();await once(agent.ws,'close'); const second=await client(f.url,{role:'agent',code:'wwRuboh'});assert.equal((await second.next('ready')).online,true); device.send({type:'ping'});assert.equal((await device.next('pong')).attached,true); diff --git a/slab/bin/ww.mjs b/slab/bin/ww.mjs index 757a5c0174..0d56d0ef71 100644 --- a/slab/bin/ww.mjs +++ b/slab/bin/ww.mjs @@ -5,8 +5,8 @@ import {WebSocket} from 'ws'; import {createHash,randomUUID} from 'node:crypto'; import {readFileSync} from 'node:fs'; const [command='list',code,...words]=process.argv.slice(2); -if(!['list','inspect','watch','ask','undo','edit'].includes(command))throw Error('Usage: ww.mjs list | inspect CODE | watch CODE | ask CODE WORDS | undo CODE | edit CODE FILE BASE_VERSION BASE_HASH'); -const replacement=command==='edit'?readFileSync(words[0],'utf8'):null; +if(!['list','inspect','watch','ask','undo','edit','layout'].includes(command))throw Error('Usage: ww.mjs list | inspect CODE | watch CODE | ask CODE WORDS | undo CODE | edit CODE FILE BASE_VERSION BASE_HASH | layout CODE CSS_FILE'); +const replacement=['edit','layout'].includes(command)?readFileSync(words[0],'utf8'):null; if(command==='edit'&&(!/^\d+$/.test(words[1]||'')||!/^[a-f0-9]{64}$/.test(words[2]||'')))throw Error('An edit requires the version and SHA-256 from your inspected source'); const session=new ACSession(),token=await session.token(); if(!token)throw Error('Sign in with ac-login first'); @@ -26,7 +26,7 @@ if(command==='list'||command==='inspect') { if(!m.online){console.error('Device offline');process.exitCode=1;ws.close();return;} const v=m.thread.ledger?.versions.find(v=>v.id===m.thread.ledger.head); if(!v){console.error('No synchronized version');process.exitCode=1;ws.close();return;} - ws.send(JSON.stringify({type:'command',id:randomUUID(),action:command,text:command==='edit'?'Remote source edit':words.join(' '),source:replacement,baseVersion:command==='edit'?Number(words[1]):v.id,baseHash:command==='edit'?words[2]:createHash('sha256').update(v.source).digest('hex')})); + ws.send(JSON.stringify({type:'command',id:randomUUID(),action:command,text:command==='edit'?'Remote source edit':words.join(' '),source:command==='edit'?replacement:null,css:command==='layout'?replacement:undefined,baseVersion:command==='edit'?Number(words[1]):v.id,baseHash:command==='edit'?words[2]:createHash('sha256').update(v.source).digest('hex')})); } if(['result','error','accepted'].includes(m.type))console.log(JSON.stringify(m)); if(m.type==='result'||m.type==='error'){if(m.type==='error'||!m.ok)process.exitCode=1;ws.close();}