From 37dafc567e25235fc8fdc75119f62e8fbd0a7b16 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 15 Sep 2026 14:32:53 -0700 Subject: [PATCH] Open temporary Easel artifacts with hash-tilde codes --- easel/docs/live-broadcast.md | 2 +- easel/test/live-broadcast-api.test.mjs | 2 +- easel/test/watch-client.test.mjs | 2 ++ system/backend/easel-live.mjs | 9 +++++++-- system/public/aesthetic.computer/boot.mjs | 7 +++++++ system/public/aesthetic.computer/disks/prompt.mjs | 3 +++ system/public/watch/client.mjs | 8 ++++---- system/public/watch/watch.mjs | 2 +- 8 files changed, 26 insertions(+), 9 deletions(-) diff --git a/easel/docs/live-broadcast.md b/easel/docs/live-broadcast.md index c04d9c198c..d578d7e508 100644 --- a/easel/docs/live-broadcast.md +++ b/easel/docs/live-broadcast.md @@ -1,6 +1,6 @@ # Live draft broadcasts -Every signed-in artifact automatically reserves a stable QR and broadcasts its saved preview. Reopening resumes the same link; there is no `/live` command. Waiting viewers reconnect automatically. The selected artifact renews an unchanged frame before its one-hour expiry; closing the app leaves the last frame available until expiry. Publication remains separate. +Every signed-in artifact automatically reserves a stable QR and broadcasts its saved preview. Reopening resumes the same link; there is no `/live` command. Waiting viewers reconnect automatically. The selected artifact renews an unchanged frame before its one-hour expiry; closing the app leaves the last frame available until expiry. Publication remains separate. The label `#~` plus the first 12 hexadecimal ID characters is a temporary AC command. Redis atomically binds this alias to the full ID, rejects collisions, and expires it with the frame. Both the AC prompt and URL fragment route to the watch viewer. `/api/easel-live` keeps one current draft frame per broadcast in Redis for one hour. It creates no painting, published piece, or permanent media record. A watch link is a read capability: anyone holding it can view the current draft until stopped or expired. Writes require an authenticated account with an AC handle and remain bound to the stable account subject. diff --git a/easel/test/live-broadcast-api.test.mjs b/easel/test/live-broadcast-api.test.mjs index d9b8fc128f..fc5e959598 100644 --- a/easel/test/live-broadcast-api.test.mjs +++ b/easel/test/live-broadcast-api.test.mjs @@ -39,7 +39,7 @@ test('draft Paper and Game Boy source can be watched before compilation',async() test('Redis adapter performs one atomic script with hashed owner keys and reads metadata without frame bytes',async()=>{ let call;const redis={eval:async(script,options)=>{call={script,options};return 1;},hmGet:async(key,fields)=>{assert.ok(!fields.includes('data'));return fields.map(k=>k==='kind'?'picture':null);}}; const store=redisLiveStore(redis);await store.write(id(1),'private-auth-sub',{sequence:7,status:'live',kind:'picture',mime:'image/png',version:2,data:'eA==',bytes:1},1000); - assert.equal(call.script,WRITE_SCRIPT);assert.equal(call.options.keys.length,4);assert.ok(call.options.keys.every(k=>!k.includes('private-auth-sub')));assert.equal(call.options.arguments[1],'7');assert.equal(call.options.arguments[9],String(LIVE_TTL));await store.read(id(1)); + assert.equal(call.script,WRITE_SCRIPT);assert.equal(call.options.keys.length,5);assert.ok(call.options.keys.every(k=>!k.includes('private-auth-sub')));assert.equal(call.options.arguments[1],'7');assert.equal(call.options.arguments[9],String(LIVE_TTL));await store.read(id(1)); }); test('30 independent makers and late viewers: 20 updates each, 64 KiB frames, no cross-session leakage',async t=>{ const f=fixture(),started=performance.now();let transferred=0; diff --git a/easel/test/watch-client.test.mjs b/easel/test/watch-client.test.mjs index 68f88706c9..31549cd34a 100644 --- a/easel/test/watch-client.test.mjs +++ b/easel/test/watch-client.test.mjs @@ -9,3 +9,5 @@ test('polling is sequential and visibility abort prevents late frame display',as test('new and expired links wait and recover when the maker connects',async()=>{for(const code of [404,410]){let online=false;const f=fixture(async url=>online?(url.includes('&frame=')?frame():metadata()):{status:code});await f.watch.poll();assert.deepEqual(f.ends,['waiting']);assert.equal(f.watch.running,true);assert.ok([...f.timers.values()].some(t=>t.delay===3000));online=true;await f.watch.poll();assert.equal(f.frames.length,1);f.watch.stop();}}); test('connection errors back off without discarding successful frame',async()=>{let online=true;const f=fixture(async url=>{if(!online)throw new Error('offline');return url.includes('&frame=')?frame():metadata();});await f.watch.poll();online=false;await f.watch.poll();await f.watch.poll();assert.equal(f.frames.length,1);assert.equal(f.watch.sequence,1);assert.equal(f.errors.length,2);assert.ok([...f.timers.values()].some(t=>t.delay===1500));f.watch.stop();}); test('invalid metadata and oversized frames never reach rendering',async()=>{assert.throws(()=>validateMetadata({...meta,kind:'html'},id));assert.throws(()=>new LiveWatch({id:'../secret'}));const f=fixture(async url=>url.includes('&frame=')?{...frame(),headers:{get:()=>String(MAX_FRAME_BYTES+1)}}:metadata());await f.watch.poll();assert.equal(f.frames.length,0);assert.match(f.errors[0],/too large/);f.watch.stop();}); + +test('temporary code resolves metadata then fetches the exact artifact ID',async()=>{const requests=[];const f=fixture(async url=>{requests.push(url);return url.includes('&frame=')?frame():metadata();});f.watch.id=id.slice(0,12);await f.watch.poll();assert.equal(requests[0],'/api/easel-live?code='+id.slice(0,12));assert.equal(requests[1],'/api/easel-live?id='+id+'&frame=1');assert.equal(f.frames.length,1);f.watch.stop();}); diff --git a/system/backend/easel-live.mjs b/system/backend/easel-live.mjs index c1d34c7b79..545afcc279 100644 --- a/system/backend/easel-live.mjs +++ b/system/backend/easel-live.mjs @@ -11,6 +11,8 @@ const reply=(statusCode,value)=>({statusCode,headers:{...headers,'Content-Type': export const WRITE_SCRIPT=` local owner=redis.call('HGET',KEYS[1],'owner') if owner and owner~=ARGV[1] then return -1 end +local alias=redis.call('GET',KEYS[5]) +if alias and alias~=ARGV[12] then return -6 end local seq=tonumber(redis.call('HGET',KEYS[1],'sequence') or '0') if tonumber(ARGV[2])<=seq then return -2 end if ARGV[3]=='stopped' and not owner then return -5 end @@ -29,12 +31,14 @@ redis.call('HSET',KEYS[1],'owner',ARGV[1],'sequence',ARGV[2],'status',ARGV[3],'u if ARGV[3]=='live' then redis.call('HSET',KEYS[1],'kind',ARGV[4],'version',ARGV[5],'mime',ARGV[6],'data',ARGV[7]) else redis.call('HDEL',KEYS[1],'data') end +redis.call('SET',KEYS[5],ARGV[12],'EX',ARGV[10]) redis.call('EXPIRE',KEYS[1],ARGV[10]) return 1`; export function redisLiveStore(redis,{prefix='easel-live:'}={}) { return { + async resolve(code){return redis.get(prefix+'code:'+code);}, async read(id,frame=false){const fields=frame?[...FIELDS,'data']:FIELDS;const values=await redis.hmGet(prefix+id,fields);if(values[0]===null)return null;return Object.fromEntries(fields.map((key,i)=>[key,values[i]]));}, - async write(id,owner,document,now){const ownerKey=createHash('sha256').update(owner).digest('hex');const minute=Math.floor(now/60000);return Number(await redis.eval(WRITE_SCRIPT,{keys:[prefix+id,prefix+'owner:'+ownerKey,prefix+'rate:'+ownerKey+':'+minute,prefix+'bytes:'+ownerKey+':'+minute],arguments:[owner,String(document.sequence),document.status,document.kind||'',String(document.version||''),document.mime||'',document.data||'',String(now),String(now+LIVE_TTL*1000),String(LIVE_TTL),String(document.bytes||0)]}));} + async write(id,owner,document,now){const ownerKey=createHash('sha256').update(owner).digest('hex');const minute=Math.floor(now/60000);return Number(await redis.eval(WRITE_SCRIPT,{keys:[prefix+id,prefix+'owner:'+ownerKey,prefix+'rate:'+ownerKey+':'+minute,prefix+'bytes:'+ownerKey+':'+minute,prefix+'code:'+id.slice(0,12)],arguments:[owner,String(document.sequence),document.status,document.kind||'',String(document.version||''),document.mime||'',document.data||'',String(now),String(now+LIVE_TTL*1000),String(LIVE_TTL),String(document.bytes||0),id]}));} }; } function metadata(id,row){return {id,route:`https://aesthetic.computer/watch/?id=${id}`,kind:row.kind,version:Number(row.version),mime:row.mime,sequence:Number(row.sequence),status:row.status,updatedAt:new Date(Number(row.updatedAt)).toISOString(),expiresAt:new Date(Number(row.expiresAt)).toISOString()};} @@ -45,7 +49,8 @@ export function createLiveHandler({authorize,getHandleOrEmail,store,now=Date.now if(!['GET','POST','DELETE'].includes(method))return reply(405,{error:'Use GET, POST or DELETE'}); try { if(method==='GET'){ - const {id,frame}=event.queryStringParameters||{}; + let {id,frame,code}=event.queryStringParameters||{}; + if(code!==undefined){if(!/^[a-f0-9]{12}$/.test(code)||id!==undefined)return reply(400,{error:'Invalid draft code'});id=await store.resolve(code);if(!id)return reply(404,{error:'Draft not connected'});} if(!ID.test(id||'')||(frame!==undefined&&!/^[1-9][0-9]{0,14}$/.test(frame)))return reply(400,{error:'Invalid draft ID or frame sequence'}); const row=await store.read(id,frame!==undefined);if(!row)return reply(404,{error:'Draft expired or not found'}); if(frame===undefined)return reply(200,metadata(id,row)); diff --git a/system/public/aesthetic.computer/boot.mjs b/system/public/aesthetic.computer/boot.mjs index c96ea937e3..dc34adcb92 100644 --- a/system/public/aesthetic.computer/boot.mjs +++ b/system/public/aesthetic.computer/boot.mjs @@ -2,6 +2,13 @@ import { NOPAINT_SESSION_SEED_KEY, noPaintStartingPiece } from "./lib/nopaint-na // `aesthetic.computer` Bootstrap, 23.02.16.19.23 +// Temporary Easel draft codes open the current live artifact. +const draftCode = /^#~([a-f0-9]{12})$/i.exec(window.location.hash); +if (draftCode) { + window.location.replace(`https://aesthetic.computer/watch/?code=${draftCode[1].toLowerCase()}`); + await new Promise(() => {}); +} + // Painting permalinks belong to Aesthetic Computer. Fragments never reach the // server, so branded Laklok hosts must hand them off before booting the piece. const laklokHost = diff --git a/system/public/aesthetic.computer/disks/prompt.mjs b/system/public/aesthetic.computer/disks/prompt.mjs index 1285980db8..537dfe87d7 100644 --- a/system/public/aesthetic.computer/disks/prompt.mjs +++ b/system/public/aesthetic.computer/disks/prompt.mjs @@ -4681,6 +4681,9 @@ async function halt($, text) { // Disconnect from socket server, chat, and udp in 5 seconds... net.hiccup(); return true; + } else if (/^#~[a-f0-9]{12}$/i.test(text.trim())) { + jump(`https://aesthetic.computer/watch/?code=${text.trim().slice(2).toLowerCase()}`); + return true; } else if (text.startsWith("#")) { // Handle painting short codes like #k3d, #WDv const code = text.slice(1).trim(); diff --git a/system/public/watch/client.mjs b/system/public/watch/client.mjs index 151a755a93..3e1af9f34a 100644 --- a/system/public/watch/client.mjs +++ b/system/public/watch/client.mjs @@ -1,6 +1,6 @@ export const MAX_FRAME_BYTES = 16 * 1024 * 1024; export function validateMetadata(value, id) { - if (!value || value.id !== id || !['picture','sound','paper','gameboy','piece'].includes(value.kind) + if (!value || (id.length===12 ? !value.id?.startsWith(id) : value.id !== id) || !['picture','sound','paper','gameboy','piece'].includes(value.kind) || !Number.isSafeInteger(value.sequence) || value.sequence < 1 || !Number.isSafeInteger(value.version) || value.version < 1 || !['live','stopped'].includes(value.status) || typeof value.mime !== 'string') throw new Error('Invalid preview metadata'); @@ -16,7 +16,7 @@ async function readFrame(response) { } export class LiveWatch { constructor({id,fetch=globalThis.fetch.bind(globalThis),onFrame=async()=>{},onStatus=()=>{},onEnd=()=>{},onError=()=>{},schedule=(fn,delay)=>globalThis.setTimeout(fn,delay),cancel=timer=>globalThis.clearTimeout(timer)}={}) { - if(!/^[a-f0-9]{32}$/.test(id||''))throw new Error('This watch link is incomplete'); + if(!/^(?:[a-f0-9]{12}|[a-f0-9]{32})$/.test(id||''))throw new Error('This watch link is incomplete'); Object.assign(this,{id,fetch,onFrame,onStatus,onEnd,onError,schedule,cancel}); this.visible=true;this.running=false;this.sequence=0;this.retry=750;this.epoch=0; } @@ -31,7 +31,7 @@ export class LiveWatch { let delay=750; try{ const options={credentials:'omit',cache:'no-store',referrerPolicy:'no-referrer',signal:controller.signal}; - const response=await this.fetch(`/api/easel-live?id=${this.id}`,options); + const response=await this.fetch(`/api/easel-live?${this.id.length===12?"code":"id"}=${this.id}`,options); if(response.status===404||response.status===410){this.sequence=0;this.onEnd('waiting');delay=3000;return;} if(!response.ok)throw new Error('Could not connect to this preview'); const metadata=validateMetadata(await response.json(),this.id); @@ -39,7 +39,7 @@ export class LiveWatch { if(metadata.status==='stopped'){this.sequence=0;this.onEnd('waiting');delay=3000;return;} this.onStatus(metadata,this.sequence); if(metadata.sequence>this.sequence){ - const frame=await this.fetch(`/api/easel-live?id=${this.id}&frame=${metadata.sequence}`,options); + const frame=await this.fetch(`/api/easel-live?id=${metadata.id}&frame=${metadata.sequence}`,options); if(frame.status===409){delay=250;return;} if(frame.status===404||frame.status===410){this.sequence=0;this.onEnd('waiting');delay=3000;return;} if(!frame.ok)throw new Error('Could not load the new version'); diff --git a/system/public/watch/watch.mjs b/system/public/watch/watch.mjs index fa6b089964..4952b892a0 100644 --- a/system/public/watch/watch.mjs +++ b/system/public/watch/watch.mjs @@ -35,7 +35,7 @@ async function display(metadata,bytes,signal){ } let watch; try{ - const id=new URLSearchParams(location.search).get('id'); + const id=new URLSearchParams(location.search).get('id') || new URLSearchParams(location.search).get('code'); watch=new LiveWatch({id,onFrame:display,onStatus:(metadata,sequence)=>{status.textContent=metadata.sequence>sequence?'Updating…':'Live draft';},onEnd:reason=>{clear();label.textContent='Easel preview';status.textContent='Waiting for the artifact…';const p=document.createElement('p');p.textContent='The preview will appear here when the maker connects.';artifact.append(p);},onError:message=>status.textContent=message}); watch.setVisible(!document.hidden);watch.start(); document.addEventListener('visibilitychange',()=>{watch.setVisible(!document.hidden);if(document.hidden)player?.pause();}); -- 2.51.2