diff --git a/easel/.gitignore b/easel/.gitignore index 5db4ec55f6..734edfaefa 100644 --- a/easel/.gitignore +++ b/easel/.gitignore @@ -2,3 +2,5 @@ .build/ dist/ *.log + +native/gamepad diff --git a/easel/SCORE.md b/easel/SCORE.md index 75289abca6..09bae45881 100644 --- a/easel/SCORE.md +++ b/easel/SCORE.md @@ -7,7 +7,7 @@ Build a new piece of aesthetic.computer software. The running artifact is the wo - Tool discovery must be narrow: the AC tools are `ac_api`, `ac_examples`, `ac_outline`, `ac_symbol`, `ac_references`, `ac_preview`, and `ac_frame` on the `ac` MCP server. If discovery is needed, match the exact tool name and print only its schema. Never dump `ALL_TOOLS` or match generic terms like "input" or "publish" across every server. Show names with short summaries when listing tools. Preview refresh and auto-publish are managed by Aesel; do not discover unrelated publishing tools for them. - Navigate JavaScript structurally: `ac_outline` gives AST declarations and class methods; `ac_symbol` reads one declaration; `ac_references` finds identifier occurrences without matching comments or strings. These are syntax tools, not a type checker or scope-aware LSP. Bundled `lib/graph.mjs`, `lib/disk.mjs`, `lib/cam-doll.mjs` and reference pieces are available through these tools. - Avoid repeatedly paging through runtime files with sed/head/grep. If a tool fails, identify that failure once and use one bounded source lookup. Never describe unavailable tools as successful verification. -- Keep animation in sim/paint and input in act. Use screen dimensions for responsive composition. Follow the bundled SCREEN and HAND guides. +- Keep animation in sim/paint and input in act. Use current canvas dimensions for responsive composition. Contain the full subject with proportional margins by default; fit both axes without stretching or accidental clipping, including during resizing. Preserve simulation state and input alignment. Crop only when intentional. Follow the bundled SCREEN and HAND guides. - After an edit, inspect `ac_preview` for errors at the current revision and `ac_frame` for actual pixels. For interactive work, exercise controls and verify the requested outcome. A build passing or nonempty frame is insufficient evidence of playability. - Show progress and the result in plain language. Do not dump source or tool payloads into chat. When code is needed or requested, use a fenced block with its language (`js`, `lisp`, etc.) so Aesel can highlight it. - Report what changed, what was checked, and any remaining limitation briefly. Do not claim publishing or visual success until its result is confirmed. diff --git a/easel/context/api.json b/easel/context/api.json index 585483446b..7e2c68991c 100644 --- a/easel/context/api.json +++ b/easel/context/api.json @@ -120,8 +120,7 @@ "source": "lib/graph.mjs:2797", "examples": [ "disks/flap.mjs:50 if (store[`flap~${num}`]) stamp(store[`flap~${num}`], screen.width / 2, screen.height / 2);", - "disks/graphics.mjs:28 stamp(", - "disks/kokazo.mjs:243 stamp(ink, color, x, y, jx, jy, r);" + "disks/graphics.mjs:28 stamp(" ] }, { @@ -731,7 +730,7 @@ "examples": [ "disks/bgm.mjs:26 if (params.length === 0) params[0] = num.randInt(trackCount - 1);", "disks/crayon.mjs:86 let numDots = num.randInt(minNumDots, maxNumDots);", - "disks/dafu.mjs:101 const speed = num.randInt(20) / 10 + 0.4;" + "disks/doodle.mjs:76 const x = points[i][0] + -1 + num.randInt(2);" ] }, { diff --git a/easel/context/score.md b/easel/context/score.md index ba5d384bc3..ec062f84f1 100644 --- a/easel/context/score.md +++ b/easel/context/score.md @@ -11,7 +11,7 @@ Build a new piece of aesthetic.computer software. The running artifact is the wo - Tool discovery must be narrow: the AC tools are `ac_api`, `ac_examples`, `ac_outline`, `ac_symbol`, `ac_references`, `ac_preview`, and `ac_frame` on the `ac` MCP server. If discovery is needed, match the exact tool name and print only its schema. Never dump `ALL_TOOLS` or match generic terms like "input" or "publish" across every server. Show names with short summaries when listing tools. Preview refresh and auto-publish are managed by Aesel; do not discover unrelated publishing tools for them. - Navigate JavaScript structurally: `ac_outline` gives AST declarations and class methods; `ac_symbol` reads one declaration; `ac_references` finds identifier occurrences without matching comments or strings. These are syntax tools, not a type checker or scope-aware LSP. Bundled `lib/graph.mjs`, `lib/disk.mjs`, `lib/cam-doll.mjs` and reference pieces are available through these tools. - Avoid repeatedly paging through runtime files with sed/head/grep. If a tool fails, identify that failure once and use one bounded source lookup. Never describe unavailable tools as successful verification. -- Keep animation in sim/paint and input in act. Use screen dimensions for responsive composition. Follow the bundled SCREEN and HAND guides. +- Keep animation in sim/paint and input in act. Use current canvas dimensions for responsive composition. Contain the full subject with proportional margins by default; fit both axes without stretching or accidental clipping, including during resizing. Preserve simulation state and input alignment. Crop only when intentional. Follow the bundled SCREEN and HAND guides. - After an edit, inspect `ac_preview` for errors at the current revision and `ac_frame` for actual pixels. For interactive work, exercise controls and verify the requested outcome. A build passing or nonempty frame is insufficient evidence of playability. - Show progress and the result in plain language. Do not dump source or tool payloads into chat. When code is needed or requested, use a fenced block with its language (`js`, `lisp`, etc.) so Aesel can highlight it. - Report what changed, what was checked, and any remaining limitation briefly. Do not claim publishing or visual success until its result is confirmed. diff --git a/easel/desktop/.gitignore b/easel/desktop/.gitignore index 44158296cf..5168ae684f 100644 --- a/easel/desktop/.gitignore +++ b/easel/desktop/.gitignore @@ -4,3 +4,5 @@ dist/ # Per-developer signing material stays in the local keychain/worktree. *.provisionprofile + +native/credit-label diff --git a/easel/desktop/index.html b/easel/desktop/index.html index 4a5558a12d..d714afac43 100644 --- a/easel/desktop/index.html +++ b/easel/desktop/index.html @@ -2,6 +2,7 @@ Aesel -
+
+ - + diff --git a/easel/desktop/local-preview.cjs b/easel/desktop/local-preview.cjs index 9a3abac2d8..ff3a9b1709 100644 --- a/easel/desktop/local-preview.cjs +++ b/easel/desktop/local-preview.cjs @@ -13,7 +13,10 @@ async function localPreview(workspace, preview) { if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Preview must be inside the media project.'); const stat = fs.statSync(target); if (!stat.isFile() || stat.size > 32*1024*1024) throw new Error('Preview exceeds the 32 MiB display limit.'); - let dimensions; + // A PDF page is displayed as US Letter portrait unless platform metadata + // proves otherwise. This also fixes page geometry off macOS, where `sips` + // is unavailable and the old generic 3:2 card made papers look landscape. + let dimensions = preview.mime === 'application/pdf' ? {width:612,height:792} : undefined; if (preview.mime === 'application/pdf' && process.platform === 'darwin') { const key = JSON.stringify([target,preview.version,stat.size,stat.mtimeMs]); if (!dimensionsCache.has(key)) { @@ -27,9 +30,9 @@ async function localPreview(workspace, preview) { if (dimensionsCache.size >= 32) dimensionsCache.delete(dimensionsCache.keys().next().value); dimensionsCache.set(key,result); } - dimensions = dimensionsCache.get(key); + dimensions = dimensionsCache.get(key) || dimensions; } const bytes = fs.readFileSync(target); - return {...dimensions,mime:preview.mime, data:`data:${preview.mime};base64,${bytes.toString('base64')}`,text:preview.mime==='text/plain'?bytes.toString('utf8').slice(0,100000):undefined}; + return {...dimensions,mime:preview.mime,internalPath:target,data:`data:${preview.mime};base64,${bytes.toString('base64')}`,text:preview.mime==='text/plain'?bytes.toString('utf8').slice(0,100000):undefined}; } module.exports = {localPreview}; diff --git a/easel/desktop/main.cjs b/easel/desktop/main.cjs index 3e420e2e65..8a376a4fcf 100644 --- a/easel/desktop/main.cjs +++ b/easel/desktop/main.cjs @@ -1,20 +1,38 @@ const {chmodSync}=require('node:fs'); -const { app, BrowserWindow, ipcMain, shell, Menu, clipboard, screen } = require('electron'); +const { app, BrowserWindow, ipcMain, shell, Menu, clipboard, screen, nativeImage } = require('electron'); const pty = require('node-pty'); +const { spawn } = require('node:child_process'); const { join, resolve } = require('node:path'); -const { mkdirSync, readdirSync, readFileSync, writeFileSync, watch, existsSync, rmSync, renameSync } = require('node:fs'); +const { closeSync, copyFileSync, mkdirSync, openSync, readdirSync, readFileSync, writeFileSync, watch, existsSync, rmSync, renameSync } = require('node:fs'); const {followSlabTheme,FALLBACK} = require('./slab-theme.cjs'); const {createHash,randomUUID} = require('node:crypto'); const {localPreview} = require('./local-preview.cjs'); const {createUpdater} = require('./updater.cjs'); const {startFrameCapture} = require('./frame-capture.cjs'); -const { tmpdir } = require('node:os'); +const { tmpdir, homedir } = require('node:os'); app.setName('aesel'); app.setPath('userData', join(app.getPath('appData'), 'Easel')); -let window, terminal, timer, ledgerWatcher, quitting = false; +let window, terminal, timer, quitting = false; +let lastVisibleState = null, keepPreviewOnStart = false; let terminalSize = {cols:100,rows:32}; let currentTheme = FALLBACK; +let currentPaperPath = ''; +let currentPreviewPath = '', currentPreviewMime = '', currentPreviewName = ''; +const dragDir=join(tmpdir(),`easel-drag-${process.pid}`); +let systemTextSize = null, systemTextWatcher = null; +const systemTextSizePath = join(homedir(), '.local', 'share', 'slab', 'state', 'prompt-text-size.json'); +function readSystemTextSize() { + try { + const value = JSON.parse(readFileSync(systemTextSizePath, 'utf8')); + if (Number.isInteger(value.fontSize) && value.fontSize >= 8 && value.fontSize <= 48) { + systemTextSize = {fontSize:value.fontSize, mode:['far','near','tiny'].includes(value.mode) ? value.mode : ''}; + send('system-text-size', systemTextSize); + } + } catch {} +} +try { systemTextWatcher = watch(join(homedir(), '.local', 'share', 'slab', 'state'), (_event,name) => { if (name === 'prompt-text-size.json') readSystemTextSize(); }); } catch {} +readSystemTextSize(); const themeFollower = followSlabTheme(theme => { currentTheme = theme; if (window && !window.isDestroyed()) { window.setBackgroundColor(theme.background); send('theme',theme); } }); const startedAt = Date.now(); const root = app.isPackaged ? join(process.resourcesPath, 'easel') : resolve(__dirname, '..'); @@ -31,15 +49,48 @@ const independentWindow = /^window-[a-f0-9-]{36}$/.test(instance); if(independentWindow){const sessionData=join(app.getPath('userData'),'window-sessions',instance);mkdirSync(sessionData,{recursive:true,mode:0o700});app.setPath('sessionData',sessionData);} const primaryInstance = independentWindow || app.requestSingleInstanceLock(); if (!primaryInstance) app.quit(); -else { mkdirSync(app.getPath('userData'),{recursive:true,mode:0o700}); writeFileSync(launchFile,JSON.stringify({cwd:workspace}),{mode:0o600}); } -app.on('second-instance', (_event, _argv, _cwd, data) => { - if (window) { if (window.isMinimized()) window.restore(); window.show(); window.focus(); } - if (data?.restart && data.workspace === workspace) requestRestart('restart'); +mkdirSync(app.getPath('userData'),{recursive:true,mode:0o700}); +writeFileSync(launchFile,JSON.stringify({cwd:workspace}),{mode:0o600}); +const addressDir = join(app.getPath('userData'), 'instance-addresses'); +mkdirSync(addressDir,{recursive:true,mode:0o700}); +function claimAddress() { + for (let index=0; index<26; index++) { + const label=String.fromCharCode(65+index), path=join(addressDir,`${label}.json`); + try { + const owner=JSON.parse(readFileSync(path,'utf8')); + try { process.kill(Number(owner.pid),0); continue; } catch { rmSync(path,{force:true}); } + } catch {} + try { + const fd=openSync(path,'wx',0o600); + writeFileSync(fd,JSON.stringify({pid:process.pid,instance,workspace,startedAt:Date.now()})); + closeSync(fd); + return {label,path}; + } catch {} + } + return {label:String(process.pid),path:''}; +} +const address=primaryInstance?claimAddress():{label:'',path:''}; +function releaseAddress(){ + if(!address.path)return; + try { const owner=JSON.parse(readFileSync(address.path,'utf8')); if(Number(owner.pid)===process.pid)rmSync(address.path,{force:true}); } catch {} +} +app.once('will-quit',()=>{releaseAddress();try{rmSync(dragDir,{recursive:true,force:true});}catch{}}); +process.once('exit',releaseAddress); +app.on('second-instance',()=>{if(!window||window.isDestroyed())return;if(window.isMinimized())window.restore();window.show();window.focus();}); +// `open -a Easel` activates an existing macOS process without necessarily +// launching a second instance. A checkpointed restart can therefore leave the +// process healthy but its previously hidden window unreachable unless the app +// handles the ordinary Dock/reopen activation itself. +app.on('activate', () => { + if (!window || window.isDestroyed()) return; + if (window.isMinimized()) window.restore(); + window.show(); + window.focus(); }); mkdirSync(workspace, { recursive: true }); const sessionDir = join(app.getPath('userData'), 'sessions'); mkdirSync(sessionDir, {recursive:true,mode:0o700}); -const sessionFile = join(sessionDir, createHash('sha256').update(workspace).digest('hex') + '.json'); +const sessionFile = join(sessionDir, createHash('sha256').update(`${workspace}\0${instance}`).digest('hex') + '.json'); const continuationFile = sessionFile + '.continue'; let continueSession = false; if (primaryInstance) { @@ -74,18 +125,15 @@ function requestRestart(action = 'restart') { writeFileSync(intentFile, JSON.stringify({action}), {mode:0o600}); terminal.kill('SIGUSR2'); } -// Mac App Store owns updates for its sandboxed build. The Developer ID build -// uses aesel's generic signed feed; never let electron-updater compete with -// Store receipts or present a non-Store update inside the MAS app. const canUpdateBinary = app.isPackaged && !process.mas && existsSync(join(process.resourcesPath,'app-update.yml')); const desktopUpdater = createUpdater({app, canUpdateBinary, requestRestart, prepareRelaunch: () => writeFileSync(continuationFile,JSON.stringify({cwd:workspace,at:Date.now()}),{mode:0o600}), notify: message => send('desktop-notice', message)}); function send(channel, data) { if (window && !window.isDestroyed()) window.webContents.send(channel, data); } -function start({home = false} = {}) { +function start() { if (terminal) return; const args = [join(root, 'src/tui.mjs'), '--cwd', workspace]; - for (const name of ['--piece', '--resume', '--model', '--backend', '--effort']) if (option(name) && !(home && ['--piece','--resume'].includes(name))) args.push(name, option(name)); - if (continueSession && !home) args.push('--continue-session'); + for (const name of ['--piece', '--resume', '--model', '--backend', '--effort']) if (option(name)) args.push(name, option(name)); + if (continueSession) args.push('--continue-session'); if (supplied.includes('--no-autopublish')) args.push('--no-autopublish'); const env = { ...process.env, ELECTRON_RUN_AS_NODE: '1', TERM: 'xterm-256color', COLORTERM: 'truecolor', SLAB_HOME: slabHome, EASEL_DESKTOP: '1', EASEL_KEEP_PREVIEW:keepPreviewOnStart?'1':'0', EASEL_HOST_PID:String(process.pid), EASEL_HOST_WINDOW_ID:window.getMediaSourceId().split(':')[1], EASEL_THEME:'slab', EASEL_MOUSE:'0', EASEL_DESKTOP_SESSION:sessionFile, EASEL_DESKTOP_CONTROL:controlFile, EASEL_DESKTOP_INTENT:intentFile, EASEL_PREVIEW_EVENTS:previewEventsFile }; delete env.NODE_OPTIONS; @@ -99,7 +147,7 @@ function start({home = false} = {}) { if(existsSync(helper))chmodSync(helper,0o755); } terminal = process.mas - ? require('./mas-terminal.cjs').createMasTerminal(args,{...terminalSize,cwd:workspace,env}) + ? require('./mas-terminal.cjs').createMasTerminal(args, { ...terminalSize, cwd: workspace, env }) : pty.spawn(process.execPath, args, { name: 'xterm-256color', ...terminalSize, cwd: workspace, env }); terminal.onData(data => send('output', data)); terminal.onExit(({ exitCode }) => { @@ -107,12 +155,6 @@ function start({home = false} = {}) { if (exitCode === 75 && existsSync(controlFile)) { const request = JSON.parse(readFileSync(controlFile, 'utf8')); rmSync(controlFile, {force:true}); - if (request.action === 'home') { - timer?.close(); ledgerWatcher?.close(); - pendingRestart = false; - send('output', '\x1b]777;easel-phase:startup\x07\x1b[2J\x1b[H'); - return start({home:true}); - } if (request.action === 'restart') { pendingRestart = false; continueSession = true; keepPreviewOnStart = true; timer?.close(); @@ -126,7 +168,7 @@ function start({home = false} = {}) { } } if (exitCode === 0) { quitting = true; app.quit(); return; } - pendingRestart = false; send('output', `\r\naesel closed (${exitCode}). Close this window to finish.\r\n`); if (quitting) app.quit(); }); + pendingRestart = false; send('output', `\r\nAesel closed (${exitCode}). Close this window to finish.\r\n`); if (quitting) app.quit(); }); } catch (error) { console.error('Agent startup failed:',error); send('desktop-notice','The agent could not start. Use Restart Agent to retry.'); } let previous = '', qrUrl = '', qr = null, previewKey = '', previewData = {}; const dir = join(slabHome, 'state/active-prompts'); @@ -141,9 +183,7 @@ function start({home = false} = {}) { const state = JSON.parse(readFileSync(join(dir, name), 'utf8')); themeFollower.setStatus(state.state); previewContext=(state.artifact_kind||'piece')==='piece'&&state.piece_channel?{channel:state.piece_channel,revision:state.piece_revision,version:state.piece_version}:null; - let proxName=null; - try {const ledger=JSON.parse(readFileSync(join(app.getPath('home'),'.config/slab/ledger/local.json'),'utf8'));const entry=ledger.entries?.find(e=>e.id===state.session_id&&e.agentType==='easel');if(entry?.proxNamespace==='easel'&&entry.proxName?.length<=100&&/^[a-z0-9_-]+(?:\/[a-z0-9_-]+)?$/.test(entry.proxName))proxName=entry.proxName;}catch{} - const visible = JSON.stringify({ proxName, handle:state.handle||"",handleColors:state.handle_colors||null, piece: state.piece, status:state.state, url: state.scan_url, version: state.piece_version, publication:state.piece_published_at || "", channel:state.piece_channel || "", flow: state.flow, medium: state.artifact_kind || 'piece', preview: state.artifact_preview || null }); + const visible = JSON.stringify({ piece: state.piece, handle:state.handle || "", handleColors:state.handle_colors || null, status:state.state, url: state.scan_url, version: state.piece_version, publication:state.piece_published_at || "", channel:state.piece_channel || "", flow: state.flow, medium: state.artifact_kind || 'piece', preview: state.artifact_preview || null }); if (visible !== previous) { previous = visible; if (state.scan_url !== qrUrl) { @@ -154,8 +194,8 @@ function start({home = false} = {}) { const nextPreviewKey = JSON.stringify(state.artifact_preview || null); if (nextPreviewKey !== previewKey) { previewKey = nextPreviewKey; - try { previewData = {localPreview:await localPreview(workspace,state.artifact_preview)}; } - catch (error) { previewData = {previewError:error.message}; } + try { const loaded=await localPreview(workspace,state.artifact_preview);currentPaperPath=loaded?.mime==='application/pdf'?loaded.internalPath:'';currentPreviewPath=loaded?.internalPath||'';currentPreviewMime=loaded?.mime||'';currentPreviewName=state.piece||'';if(loaded)delete loaded.internalPath;previewData={localPreview:loaded}; } + catch (error) { currentPaperPath=currentPreviewPath=currentPreviewMime=currentPreviewName='';previewData = {previewError:error.message}; } } lastVisibleState = {...JSON.parse(visible), qr, ...previewData}; send('state', lastVisibleState); @@ -165,7 +205,6 @@ function start({home = false} = {}) { finally {updating=false;if(updateAgain){updateAgain=false;void update();}} }; timer = watch(dir, () => { update(); }); - try {ledgerWatcher=watch(join(app.getPath('home'),'.config/slab/ledger'),(_,file)=>{if(String(file)==='local.json')update();});}catch{} update(); } function openNewWindow(){ @@ -180,17 +219,19 @@ function openNewWindow(){ app.whenReady().then(() => { if (!primaryInstance) return; Menu.setApplicationMenu(Menu.buildFromTemplate([ - { label: 'aesel', submenu: [ + { label: 'Aesel', submenu: [ {role:'about'}, {type:'separator'}, {role:'close',accelerator:'CmdOrCtrl+W'}, {label:'Check for Updates…', click:()=>desktopUpdater.check()}, - {label:'Restart aesel', click:()=>requestRestart('restart')}, + {label:'Restart Agent', click:()=>requestRestart('restart')}, + {label:'Reload Interface', click:()=>window.webContents.reload()}, + {label:'Restart App', click:()=>requestRestart('update')}, {type:'separator'}, {role:'hide'}, {role:'hideOthers'}, {role:'unhide'}, {type:'separator'}, {role:'quit'}, ] }, {label:'File',submenu:[{label:'New Window',accelerator:'CmdOrCtrl+N',click:openNewWindow}]}, { role: 'editMenu' }, { label: 'View', submenu: [ - {label:'aesel Actions…',click:()=>{const gallery=new BrowserWindow({parent:window,width:1120,height:850,title:'aesel actions',backgroundColor:'#241d35',webPreferences:{contextIsolation:true,nodeIntegration:false,sandbox:true}});gallery.loadFile(join(__dirname,'donkey-gallery.html'));}}, + {label:'Aesel Actions…',click:()=>{const gallery=new BrowserWindow({parent:window,width:1120,height:850,title:'Aesel actions',backgroundColor:'#241d35',webPreferences:{contextIsolation:true,nodeIntegration:false,sandbox:true}});gallery.loadFile(join(__dirname,'donkey-gallery.html'));}}, {label:'Larger Text',accelerator:'CmdOrCtrl+=',click:()=>send('text-size','larger')}, {label:'Smaller Text',accelerator:'CmdOrCtrl+-',click:()=>send('text-size','smaller')}, {label:'Reset Text Size',accelerator:'CmdOrCtrl+0',click:()=>send('text-size','reset')}, @@ -264,38 +305,15 @@ app.whenReady().then(() => { if (terminal) { event.preventDefault(); if (!quitting) { quitting = true; terminal.kill('SIGTERM'); setTimeout(() => { if (terminal) { quitting = false; send('desktop-notice','The session is still saving or working. Retry closing when it is ready.'); } }, 6000).unref(); } } }); }); -let rockLayoutPath='',lastRockLayout='',rockLayoutWatcher; -function checkNativeTitle(){ - try{ - const ack=JSON.parse(readFileSync(rockLayoutPath+'.ack','utf8')),expected=JSON.parse(lastRockLayout); - send('native-title',ack.title===expected.title&&ack.fontSize===expected.titleFontSize&&['titleX','titleY','titleWidth','titleHeight'].every(k=>Math.abs(ack[k]-expected[k])<1)); - }catch{send('native-title',false);} -} - -ipcMain.on('title-geometry',(event,value)=>{ - if(event.sender!==window?.webContents||!value||!['x','y','size'].every(k=>Number.isFinite(value[k]))||typeof value.visible!=='boolean')return; - const bounds=window.getBounds(),content=window.getContentBounds(); - if(value.x<0||value.y<0||value.x>content.width||value.y>content.height||value.size<16||value.size>96)return; - const layout={x:value.x+content.x-bounds.x,y:value.y+content.y-bounds.y,size:value.size,visible:value.visible}; - if(typeof value.title==='string'&&value.title.length<=120&&['titleX','titleY','titleWidth','titleHeight','titleFontSize'].every(k=>Number.isFinite(value[k])&&value[k]>=0)){ - Object.assign(layout,{title:value.title,titleX:value.titleX+content.x-bounds.x,titleY:value.titleY+content.y-bounds.y,titleWidth:value.titleWidth,titleHeight:value.titleHeight,titleFontSize:value.titleFontSize,titleColors:Array.isArray(value.titleColors)?value.titleColors.slice(0,120).map(c=>Array.isArray(c)&&c.length===3&&c.every(n=>Number.isFinite(n)&&n>=0&&n<=255)?c:[255,255,255]):[]}); - } - const serialized=JSON.stringify(layout);if(serialized===lastRockLayout)return;lastRockLayout=serialized; - try{ - const dir=join(app.getPath('home'),'.local/share/slab/state/easel-layout');mkdirSync(dir,{recursive:true,mode:0o700}); - rockLayoutPath=join(dir,`${process.pid}-${window.getMediaSourceId().split(':')[1]}.json`); - writeFileSync(rockLayoutPath+'.tmp',serialized,{mode:0o600});renameSync(rockLayoutPath+'.tmp',rockLayoutPath); - if(!rockLayoutWatcher)rockLayoutWatcher=watch(dir,(_,file)=>{if(rockLayoutPath.endsWith(String(file).replace(/\.ack$/,''))&&String(file).endsWith('.ack'))checkNativeTitle();}); - checkNativeTitle(); - }catch(error){console.warn('aesel rock layout:',error.message);} -}); -ipcMain.on('ready', event => { if (event.sender === window?.webContents) { send('theme',currentTheme); sendDisplay(true); if(lastVisibleState)send('state',lastVisibleState); if(terminal)terminal.kill('SIGWINCH'); else start(); } }); -ipcMain.on('closing', event => { if(event.sender===window?.webContents) window.hide(); }); +ipcMain.on('ready', event => { if (event.sender === window?.webContents) { send('theme',currentTheme); send('instance-label',address.label); if(systemTextSize)send('system-text-size',systemTextSize); sendDisplay(true); if(lastVisibleState)send('state',lastVisibleState); if(terminal)terminal.kill('SIGWINCH'); else start(); } }); +// Only hide while the user is actually quitting. A stale or replayed closing +// phase must not make a healthy restarted window unreachable. +ipcMain.on('closing', event => { if(event.sender===window?.webContents && quitting) window.hide(); }); ipcMain.on('input', (event, data) => { if (event.sender === window?.webContents && typeof data === 'string' && data.length < 1_048_576) terminal?.write(data); }); ipcMain.on('size', (event, { cols, rows } = {}) => { if (event.sender === window?.webContents && Number.isInteger(cols) && Number.isInteger(rows) && cols >= 32 && cols <= 500 && rows >= 10 && rows <= 300) { if(cols!==terminalSize.cols||rows!==terminalSize.rows){terminalSize={cols,rows};terminal?.resize(cols,rows);} } }); -app.on('window-all-closed', () => { rockLayoutWatcher?.close();if(rockLayoutPath){rmSync(rockLayoutPath,{force:true});rmSync(rockLayoutPath+'.ack',{force:true});} timer?.close(); ledgerWatcher?.close(); stopFrameCapture(); themeFollower.close(); terminal?.kill(); app.quit(); }); +app.on('window-all-closed', () => { timer?.close(); stopFrameCapture(); themeFollower.close(); terminal?.kill(); app.quit(); }); const isWindow = event => event.sender === window?.webContents; ipcMain.on('copy-text', (event, text) => { if (isWindow(event) && typeof text === 'string' && text.length <= 1048576) clipboard.writeText(text); }); @@ -338,6 +356,13 @@ ipcMain.on('open-piece', (event, value) => { if (url.protocol === 'https:' && ['prompt.ac','aesthetic.computer'].includes(url.hostname) && !url.username && !url.password) shell.openExternal(url.href); } catch {} }); +ipcMain.on('open-paper', event => { if(isWindow(event)&¤tPaperPath&&process.platform==='darwin')try{spawn('/usr/bin/open',['-a','Preview',currentPaperPath],{detached:true,stdio:'ignore'}).unref();}catch{} }); +ipcMain.on('preview-drag', event => { + if(!isWindow(event)||!currentPreviewPath||!existsSync(currentPreviewPath))return; + const ext={'image/png':'.png','audio/wav':'.wav','application/pdf':'.pdf','application/x-gameboy-rom':'.gb'}[currentPreviewMime];if(!ext)return; + const stem=String(currentPreviewName||'easel').replace(/\.(mjs|lisp|lua)$/i,'').replace(/[^a-z0-9._-]+/gi,'-').replace(/^-+|-+$/g,'').slice(0,80)||'easel'; + try{mkdirSync(dragDir,{recursive:true,mode:0o700});const file=join(dragDir,stem+ext);copyFileSync(currentPreviewPath,file);let icon=currentPreviewMime==='image/png'?nativeImage.createFromPath(file):nativeImage.createFromPath(join(__dirname,'assets/aesel-icon.png'));if(icon.isEmpty())return;icon=icon.resize({width:64,height:64});event.sender.startDrag({file,icon});}catch{} +}); ipcMain.on('fullscreen', (event, value) => { if (isWindow(event)) toggleFullscreen(typeof value === 'string' ? value : value?.target); }); app.on('web-contents-created', (_event, contents) => { contents.on('before-input-event', (event, input) => { diff --git a/easel/desktop/media-preview.js b/easel/desktop/media-preview.js index 3e5027f38b..012fcbcafa 100644 --- a/easel/desktop/media-preview.js +++ b/easel/desktop/media-preview.js @@ -1,37 +1,59 @@ (() => { const viewport = document.getElementById('preview-viewport'); const host = document.createElement('div'); host.id = 'media-preview'; host.hidden = true; viewport.append(host); - let key='', player=null, emulator=null, generation=0; + let draggingPreview=false,dragSent=false; + host.addEventListener('pointerdown',()=>{draggingPreview=true;dragSent=false;}); + window.addEventListener('pointermove',event=>{if(draggingPreview&&!dragSent&&(event.clientX<=0||event.clientY<=0||event.clientX>=innerWidth-1||event.clientY>=innerHeight-1)){dragSent=true;window.aesel.dragPreview?.();}}); + window.addEventListener('pointerup',()=>{draggingPreview=false;dragSent=false;}); + let key='', player=null, playerFrame=0, emulator=null, generation=0; const ready = () => { const box=document.getElementById('artifact'); box.classList.remove('refresh'); requestAnimationFrame(()=>box.classList.add('refresh')); }; window.renderMediaPreview = async state => { if (state.medium === 'piece') { ++generation; host.hidden=true; document.getElementById('piece').hidden=false; - if(player)player.pause(); if(emulator)await emulator.pause(); key=''; return; + if(player)player.pause();cancelAnimationFrame(playerFrame);playerFrame=0;if(emulator)await emulator.pause(); key=''; return; } const next=JSON.stringify([state.medium,state.preview?.artifactId,state.preview?.version]); if(next===key)return; key=next; const current=++generation; host.hidden=false; document.getElementById('piece').hidden=true; - window.setPreviewDimensions(192,128); - if(player)player.pause(); if(emulator)await emulator.pause(); host.replaceChildren(); + // Paper previews are pages, not generic media cards. US Letter points keep + // the compact, expanded and fullscreen shells at the document's real + // portrait ratio even before PDF metadata arrives (or when TeX is absent). + window.setPreviewDimensions(state.medium==='paper'?612:192,state.medium==='paper'?792:128); + if(player)player.pause();cancelAnimationFrame(playerFrame);playerFrame=0;if(emulator)await emulator.pause(); host.replaceChildren(); const preview=state.localPreview; if(!preview){host.textContent=state.previewError||'No preview yet';return;} if(preview.width && preview.height) window.setPreviewDimensions(preview.width,preview.height); try { if(preview.mime==='image/png') { - const img=new Image();img.alt=state.piece||'Picture';img.onload=()=>{if(current!==generation)return;window.setPreviewDimensions(img.naturalWidth,img.naturalHeight);ready();};img.src=preview.data;host.append(img); + const img=new Image();img.alt=state.piece||'Picture';img.draggable=false;img.onload=()=>{if(current!==generation)return;window.setPreviewDimensions(img.naturalWidth,img.naturalHeight);ready();};img.src=preview.data;host.append(img); } else if(preview.mime==='audio/wav') { player=new Audio(preview.data);const audio=player; - const canvas=document.createElement('canvas');canvas.width=192;canvas.height=90; - const play=document.createElement('button');play.textContent='Play';play.onclick=async()=>{if(audio.paused){await audio.play();play.textContent='Pause';}else{audio.pause();play.textContent='Play';}}; - audio.onended=()=>play.textContent='Play';host.append(canvas,play); + const canvas=document.createElement('canvas');canvas.className='tape-player';canvas.width=192;canvas.height=128;canvas.tabIndex=0;canvas.setAttribute('role','button');canvas.setAttribute('aria-label','Play sound; drag to scrub; drag beyond the Easel window to export');host.append(canvas); const context=new OfflineAudioContext(1,1,24000); const buffer=await context.decodeAudioData(await(await fetch(preview.data)).arrayBuffer()); if(current!==generation)return; - const samples=buffer.getChannelData(0),ctx=canvas.getContext('2d');ctx.fillStyle='#ff64ff'; - for(let x=0;x<192;x++){let peak=0;for(let i=Math.floor(x*samples.length/192);i{cancelAnimationFrame(playerFrame);playerFrame=0;if(current!==generation)return;const progress=audio.duration?audio.currentTime/audio.duration:0; + ctx.fillStyle='#07182c';ctx.fillRect(0,0,192,128); + ctx.fillStyle='#ffc80033';for(let x=0;x<192;x++)ctx.fillRect(x,64-peaks[x]*52,1,Math.max(1,peaks[x]*104)); + ctx.fillStyle='#ffc800aa';for(let x=0;x{if(audio.paused)await audio.play();else audio.pause();}; + let down=0,moved=false; + canvas.onpointerdown=e=>{down=e.clientX;moved=false;canvas.setPointerCapture(e.pointerId);}; + canvas.onpointermove=e=>{if(!canvas.hasPointerCapture(e.pointerId))return;if(Math.abs(e.clientX-down)>3)moved=true;if(moved&&audio.duration){const box=canvas.getBoundingClientRect();audio.currentTime=Math.max(0,Math.min(1,(e.clientX-box.left)/box.width))*audio.duration;draw();}}; + canvas.onpointerup=e=>{canvas.releasePointerCapture(e.pointerId);if(!moved)toggle();}; + canvas.onkeydown=e=>{if(e.key===' '||e.key==='Enter'){e.preventDefault();toggle();}else if((e.key==='ArrowLeft'||e.key==='ArrowRight')&&audio.duration){e.preventDefault();audio.currentTime=Math.max(0,Math.min(audio.duration,audio.currentTime+(e.key==='ArrowLeft'?-1:1)*audio.duration*.05));draw();}}; + audio.onplay=draw;audio.onpause=draw;audio.onended=draw;draw();ready(); } else if(preview.mime==='application/pdf') { - const frame=document.createElement('iframe');frame.title='Paper preview';frame.src=preview.data+'#view=Fit&toolbar=0&navpanes=0';frame.onload=ready;host.append(frame); + window.setPreviewDimensions(612,792); + const frame=document.createElement('iframe');frame.title='Paper preview';frame.src=preview.data+'#page=1&zoom=page-fit&toolbar=0&navpanes=0';frame.onload=ready;host.append(frame); } else if(state.medium==='gameboy' && preview.mime!=='text/plain') { // Same bundled WasmBoy TS core and graphics callback used by AC bios. if(!window.WasmBoy){await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src='vendor/wasmboy/wasmboy.js';script.onload=resolve;script.onerror=()=>reject(new Error('Could not load the bundled Game Boy emulator.'));document.head.append(script);});} diff --git a/easel/desktop/package.json b/easel/desktop/package.json index 3b97d02043..db726bdd1f 100644 --- a/easel/desktop/package.json +++ b/easel/desktop/package.json @@ -72,6 +72,10 @@ "frame-ocr", "frame-ocr.swift" ] + }, + { + "from": "../native", + "to": "easel/native" } ], "mac": { @@ -92,14 +96,6 @@ "hardenedRuntime": false, "artifactName": "aesel-${version}-mas-${arch}.${ext}" }, - "mas": { - "target": "mas", - "provisioningProfile": "build/Easel-Mac-App-Store.provisionprofile", - "entitlements": "build/entitlements.mas.plist", - "entitlementsInherit": "build/entitlements.mas.inherit.plist", - "hardenedRuntime": false, - "artifactName": "aesel-${version}-mas-${arch}.${ext}" - }, "publish": [ { "provider": "generic", diff --git a/easel/desktop/preload.cjs b/easel/desktop/preload.cjs index 87e574e471..e59d161a44 100644 --- a/easel/desktop/preload.cjs +++ b/easel/desktop/preload.cjs @@ -14,6 +14,8 @@ contextBridge.exposeInMainWorld('aesel', { }, openLink: url => { if(typeof url==='string')ipcRenderer.send('open-link',url); }, openPiece: url => { if (typeof url === 'string') ipcRenderer.send('open-piece',url); }, + openPaper: () => ipcRenderer.send('open-paper'), + dragPreview: () => ipcRenderer.send('preview-drag'), onPreviewMode: fn => ipcRenderer.on('preview-mode', (_event, mode) => { if (['compact', 'hover', 'pinned'].includes(mode)) fn(mode); }), onNotice: fn => ipcRenderer.on('desktop-notice', (_event, message) => { if (typeof message === 'string') fn(message.slice(0, 2000)); }), onFullscreenState: fn => ipcRenderer.on('fullscreen-state', (_event, state) => { @@ -25,9 +27,11 @@ contextBridge.exposeInMainWorld('aesel', { onPaste: fn => ipcRenderer.on('paste', (_event, text) => { if (typeof text === 'string') fn(text); }), onSelectAll: fn => ipcRenderer.on('select-all', () => fn()), onOutput: fn => ipcRenderer.on('output', (_event, data) => fn(data)), + onInstanceLabel: fn => ipcRenderer.on('instance-label', (_event, label) => { if (/^[A-Z]$|^\d+$/.test(label)) fn(label); }), onNativeTitle: fn => ipcRenderer.on("native-title", (_event, ready) => fn(ready === true)), onState: fn => ipcRenderer.on('state', (_event, data) => fn(data)), onTextSize: fn => ipcRenderer.on('text-size', (_event, action) => { if (['larger','smaller','reset'].includes(action)) fn(action); }), + onSystemTextSize: fn => ipcRenderer.on('system-text-size', (_event, value) => { if (Number.isInteger(value?.fontSize) && value.fontSize >= 8 && value.fontSize <= 48) fn(value); }), onDisplay: fn => ipcRenderer.on('display', (_event, value) => { if (Number.isFinite(value?.width) && Number.isFinite(value?.height) && value.width > 0 && value.height > 0) fn(value); }), onTheme: fn => ipcRenderer.on('theme', (_event, theme) => fn(theme)), onFont: fn => ipcRenderer.on('font', (_event, bitmap) => fn(bitmap)), diff --git a/easel/desktop/qr-label.css b/easel/desktop/qr-label.css index dabfbc2d1c..40fddcc76e 100644 --- a/easel/desktop/qr-label.css +++ b/easel/desktop/qr-label.css @@ -56,9 +56,26 @@ @media (prefers-reduced-motion: reduce) { #qr-label { transition: none; } :is(#qr-label,#version) .qr-letter-ink { animation: none; } - #qr-card:hover #qr-label, - #qr-card:focus-within #qr-label { transform: scale(1); } + #qr-label:hover, + #qr-label:focus-visible { transform: scale(1); } } -:is(#qr-label,#version) .qr-version { font-size: 12px; } +:is(#qr-label,#version) .qr-version { font-size: .75em; } :is(#qr-label,#version) .qr-version .qr-letter-ink { -webkit-text-stroke-width: .42px; } + +body[data-phase="startup"] #qr-label{display:none} + +body[data-prox-rock="true"] #qr-label {left:14px;} + +#qr-label {top:5px;left:5px;max-width:calc(100vw - 30px);font-size:28px;z-index:15;} +:is(#qr-label,#version) .native-prox-title {display:block;height:auto;max-width:100%;} +body[data-native-title="true"] #qr-label {opacity:1;} +body[data-prox-rock="true"] #qr-label {left:5px;} + +body[data-native-title] #qr-label:has(.native-prox-title) {opacity:1;} + +#qr-label[data-swift-letters="true"] {padding:9px;max-width:calc(100vw - 30px);} +:is(#qr-label,#version) .native-prox-letter {position:relative;vertical-align:top;} +:is(#qr-label,#version) .native-prox-letter .qr-letter-ink {position:absolute;inset:0;} +:is(#qr-label,#version) .native-prox-letter img {position:absolute;left:-9px;top:-9px;max-width:none;pointer-events:none;} +body[data-native-title] #qr-label[data-swift-letters="true"] {opacity:1;} diff --git a/easel/desktop/renderer.js b/easel/desktop/renderer.js index 492d04b602..207b7b5be3 100644 --- a/easel/desktop/renderer.js +++ b/easel/desktop/renderer.js @@ -14,7 +14,7 @@ terminal.loadAddon(fit); terminal.open(document.getElementById('terminal')); terminal.write('\x1b[?25l'); // The proportional input owns the visible caret, including after UI reload. terminal.options.linkHandler={activate:(_event,url)=>window.aesel.openLink(url)}; -const terminalLinks=window.AeselTerminalLinks.attach(terminal,{open:url=>window.aesel.openLink(url),onError:console.error}); +const terminalLinks={isLinkAtClientPoint:()=>false,dispose:()=>{}}; window.aesel.onTheme(theme => { terminal.options.theme = {...theme,background:"#00000000"}; window.SpriteLand.theme(theme.background); @@ -54,13 +54,23 @@ function sizePreviewBox() { sizeQr(); // Unifont has 16 source pixels per em; companion pixels are twice that scale. layoutProperty("--aesel-size", `${terminal.options.fontSize * 8}px`); - const compact = Math.min(176, innerWidth * .7, innerHeight * .65 * previewAspect); + const availablePreviewHeight=Math.max(24,innerHeight-110); + const ordinaryCompact = Math.min(availablePreviewHeight*previewAspect,176, innerWidth * .28, innerHeight * .32 * previewAspect); + const compact = window.currentPreviewMedium === 'paper' + ? Math.min(ordinaryCompact, innerWidth * .28, innerHeight * .34 * previewAspect) + : ordinaryCompact; const modules=(document.getElementById("qr").width||82)/2; const dpr=window.devicePixelRatio||1; const stageHeight=112*terminal.options.fontSize/8; const largeQr=modules*Math.max(3,Math.round(terminal.options.fontSize/4)); const hasQr=!document.getElementById("donkey-qr-card").hidden; - const expanded=Math.min(compact*3,innerWidth*.7,(hasQr?Math.max(compact/previewAspect,innerHeight-stageHeight-largeQr-52):innerHeight*.65)*previewAspect); + // Keep the page above the mascot/shelf region. The preview is absolutely + // positioned, so this changes only its visual footprint—not terminal or + // bottom-interface column widths. + const paperExpanded=Math.min(innerWidth*.88,availablePreviewHeight*previewAspect); + const expanded=window.currentPreviewMedium==='paper' + ? Math.max(compact,paperExpanded) + : Math.min(compact*3,innerWidth*.78,availablePreviewHeight*previewAspect); layoutProperty("--preview-qr-size",`${Math.max(1,Math.floor((Math.min(compact,compact/previewAspect)-4)*dpr/modules))*modules/dpr}px`); layoutProperty("--donkey-qr-size",`${largeQr}px`); layoutProperty("--donkey-qr-bottom",`${stageHeight+20}px`); @@ -128,6 +138,7 @@ function resize() { reportTitleGeometry(); }); } +let noticeTimer = null; window.aesel.onNotice(message => { const notice = document.getElementById('desktop-notice'); if(/^(?:Agent restarted\.|Desktop thread restored|Reloading this development build|.*up to date)/i.test(message)){notice.hidden=true;notice.textContent='';clearTimeout(noticeTimer);return;} @@ -149,8 +160,9 @@ window.aesel.onTextSize(action => { const maximum = Math.min(48, Math.floor((document.getElementById('terminal').clientWidth - 20) / (32 * (bitmapFont ? .5 : .61)))); const size = action === 'reset' ? (bitmapFont ? 16 : 14) : terminal.options.fontSize + (action === 'larger' ? 2 : -2); terminal.options.fontSize = Math.max(8, Math.min(maximum, size)); + textSizeMode = ''; try { localStorage.setItem('easel-text-size', String(terminal.options.fontSize)); } catch {} - resize(); terminal.focus(); + reportZoom(); resize(); terminal.focus(); }); window.aesel.onFont(bitmap => { bitmapFont = bitmap; @@ -158,7 +170,7 @@ window.aesel.onFont(bitmap => { terminal.options.fontSize = bitmap ? 16 : 14; terminal.options.fontWeightBold = bitmap ? '400' : '500'; terminal.options.lineHeight = bitmap ? 1 : 1.15; - resize(); + textSizeMode = ''; reportZoom(); resize(); terminal.focus(); }); // The desktop starts the TUI with EASEL_MOUSE=0, leaving ordinary drags to @@ -337,7 +349,7 @@ document.addEventListener('contextmenu', event => { contextItem('Open on Wikipedia',()=>window.aesel.openLink('https://en.wikipedia.org/w/index.php?search='+encodeURIComponent(concept))); contextSeparator(); } - contextItem('Copy', () => window.aesel.copyText(selection), !selection); + contextItem('Copy', () => window.aesel.copyText(selection||concept), !selection&&!concept); contextItem('Paste', () => window.aesel.requestPaste()); contextItem('Select all', () => { const page=document.getElementById('notebook-page');if(page&&!document.getElementById('conversation').hidden){const range=document.createRange();range.selectNodeContents(page);const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);}else{terminal.selectAll();terminal.focus();} }); contextSeparator(); @@ -494,29 +506,54 @@ titleLabel.addEventListener('click',()=>{if(shareUrl)window.aesel.openPiece(shar titleLabel.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();titleLabel.click();}}); window.installProxHover(titleLabel); window.installPreviewResize?.(); -const showQr=show=>{document.body.dataset.qrPreview=String(show&&!document.getElementById('qr-card').hidden);}; -titleLabel.addEventListener('pointerenter',()=>showQr(true));titleLabel.addEventListener('pointerleave',()=>showQr(false)); -titleLabel.addEventListener('focus',()=>showQr(true));titleLabel.addEventListener('blur',()=>showQr(false)); -window.addEventListener('blur',()=>showQr(false)); +const showQr=()=>{document.body.dataset.qrPreview='false';}; window.aesel.onNativeTitle?.(ready=>{document.body.dataset.nativeTitle=String(ready);}); for(const id of ['qr-card','donkey-qr-card'])document.getElementById(id).addEventListener('click',()=>{if(shareUrl)window.aesel.openPiece(shareUrl);}); const hoverPreview=document.getElementById('artifact-shell'); -hoverPreview.addEventListener('pointerenter',()=>{document.body.dataset.previewQr='true';}); -hoverPreview.addEventListener('pointerleave',()=>{document.body.dataset.previewQr='false';}); +hoverPreview.addEventListener('pointerenter',()=>{document.body.dataset.previewQr='false';}); + +hoverPreview.addEventListener('click',()=>{if(window.currentPreviewMedium==='paper')window.aesel.openPaper?.();}); let shareUrl = ''; let url = '', version = 0, qrFingerprint = ''; +let instanceLabel = '', titlePiece = '', textSizeMode = ''; +function reportZoom() { + const label=document.getElementById('instance-label');label.textContent=instanceLabel; + label.title=textSizeMode?`Slab ${textSizeMode[0].toUpperCase()+textSizeMode.slice(1)} text · ${terminal.options.fontSize} pt`:`${terminal.options.fontSize} pt`; +} +function updateWindowTitle() { + const mark = /^[A-Z]$/.test(instanceLabel) ? String.fromCodePoint(0x1D56C + instanceLabel.charCodeAt(0) - 65) : `[${instanceLabel}]`; + document.title = titlePiece || 'Aesel'; +} +window.aesel.onInstanceLabel(label => { + instanceLabel = label; + document.body.dataset.instance = label; + reportZoom(); + updateWindowTitle(); +}); +window.aesel.onSystemTextSize?.(value => { + textSizeMode = value.mode || ''; + // Unifont's bitmap body reads smaller than Terminal's Menlo at the same + // nominal point size. A three-point optical correction keeps them aligned. + const easelSize=Math.min(48,value.fontSize+3); + terminal.options.fontSize = easelSize; + try { localStorage.setItem('easel-text-size', String(easelSize)); } catch {} + reportZoom(); resize(); +}); window.aesel.onState(state => { + if(document.body.dataset.phase==='startup' && state.status) document.body.dataset.phase='ready'; donkey.update(state); resize(); shareUrl = state.url || ''; document.getElementById('artifact-shell').hidden = !state.url && !state.preview; if (state.medium !== 'piece' && url) { preview.src = 'about:blank'; url = ''; } window.currentPreviewMedium = state.medium; + document.body.dataset.previewMedium = state.medium || ''; if (state.medium === 'piece') window.setPreviewDimensions(...(window.previewUserDimensions || pieceDimensions || [192, 128])); window.renderMediaPreview(state).catch(error => console.error('Preview failed:', error)); const qr = document.getElementById('qr'); - document.getElementById('qr-card').hidden = !state.qr || !shareUrl; - document.getElementById('donkey-qr-card').hidden = !state.qr || !shareUrl; + const hideQr = state.medium === 'paper' || !state.qr || !shareUrl; + document.getElementById('qr-card').hidden = hideQr; + document.getElementById('donkey-qr-card').hidden = hideQr; const draftId = state.medium !== 'piece' ? /[?&]id=([a-f0-9]{32})/.exec(state.url || '')?.[1] : null; const scanName = state.preview?.publicCode ? '#' + state.preview.publicCode : draftId ? '#~' + draftId.slice(0,12) : (state.piece || 'easel').replace(/\.(mjs|lisp|lua)$/, ''); const displayName=(state.piece||scanName).split('/').at(-1).replace(/\.(mjs|lisp|lua)$/,''); @@ -542,7 +579,8 @@ window.aesel.onState(state => { qr.title = state.url; sizeQr(); } - document.title = state.piece ? `${state.piece} · Aesel` : 'Aesel'; + titlePiece = `aesel ${title}` + (Number.isInteger(state.version) ? ` v${state.version}` : ''); + updateWindowTitle(); // Owned piece routes subscribe to their live channel themselves. Wait for // the first readable publication instead of booting prompt or racing a 403. const ownedRoute = /\/@[^/]+\/[^/?#]+/.test(state.url || ''); diff --git a/easel/desktop/scripts/build-credit-label.sh b/easel/desktop/scripts/build-credit-label.sh index 613ed3f68b..d8e7a9131b 100644 --- a/easel/desktop/scripts/build-credit-label.sh +++ b/easel/desktop/scripts/build-credit-label.sh @@ -3,4 +3,5 @@ set -euo pipefail cd "$(dirname "$0")/.." if [[ "$(uname -s)" == Darwin ]]; then nice -n 10 swiftc -O native/credit-label.swift -o native/credit-label + nice -n 10 swiftc -O ../native/gamepad.swift -o ../native/gamepad fi diff --git a/easel/desktop/style.css b/easel/desktop/style.css index 3a8a614868..62b441bba7 100644 --- a/easel/desktop/style.css +++ b/easel/desktop/style.css @@ -3,7 +3,7 @@ html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#463264;col #terminal{height:calc(100% - 20px);padding:10px} #artifact-shell{position:absolute;top:14px;right:14px;width:var(--preview-width,178px);height:var(--preview-height,119.33px);transition:width .18s,height .18s;z-index:2} #artifact{position:relative;width:100%;height:100%;box-sizing:border-box;overflow:hidden;background:#1c1728;border:1px solid #ffffff55;border-radius:0;box-shadow:2px 2px #0006;transition:width .18s,height .18s;z-index:2} -body[data-preview-mode="hover"] #artifact-shell:focus-within,body[data-preview-mode="pinned"] #artifact-shell{width:var(--preview-expanded-width,434px);height:var(--preview-expanded-height,290px)} +body[data-preview-mode="hover"][data-preview-engaged="true"] #artifact-shell,body[data-preview-mode="hover"] #artifact-shell:focus-within,body[data-preview-mode="pinned"] #artifact-shell{width:var(--preview-expanded-width,434px);height:var(--preview-expanded-height,290px)} #preview-viewport{position:absolute;left:0;top:0;width:var(--preview-logical-width,192px);height:var(--preview-logical-height,128px);transform-origin:0 0;image-rendering:pixelated;overflow:hidden} #piece{display:flex;width:var(--preview-logical-width,192px);height:var(--preview-logical-height,128px);image-rendering:pixelated} #version{position:absolute;right:0;top:calc(100% + 4px);color:#fff;text-shadow:1px 1px #000;font:calc(var(--aesel-qr-label-size,16px) * .75) "AC aesel Unifont",monospace;pointer-events:none;white-space:nowrap} @@ -31,6 +31,10 @@ body.preview-fullscreen #preview-fullscreen{top:12px;right:12px} #media-preview{position:absolute;inset:0;overflow:hidden;color:white;font:10px monospace;text-align:center} #media-preview img,#media-preview iframe{width:100%;height:100%;object-fit:contain;border:0;image-rendering:pixelated} #media-preview canvas{max-width:100%;max-height:100%;object-fit:contain;image-rendering:pixelated} +#media-preview canvas.tape-player{width:100%;height:100%;cursor:grab;outline:none} +#media-preview canvas.tape-player:active{cursor:grabbing} +#media-preview canvas.tape-player:focus-visible{box-shadow:inset 0 0 0 2px var(--aesel-accent,#ff64ff)} +#media-preview iframe{pointer-events:none} #media-preview button{font:10px monospace} #media-preview pre{margin:4px;text-align:left;white-space:pre-wrap;font:6px monospace} diff --git a/easel/test/donkey.test.mjs b/easel/test/donkey.test.mjs index bb1f92bd8b..a635cf2f4a 100644 --- a/easel/test/donkey.test.mjs +++ b/easel/test/donkey.test.mjs @@ -18,7 +18,7 @@ test('malformed asset manifest cannot create an unbounded animation loop',()=>{ test('hidden windows cancel animation timers and reduced-motion working stays still',async()=>{ const {readFileSync}=await import('node:fs'),{runInNewContext}=await import('node:vm'); const listeners=new Map(),timers=new Map();let serial=0,now=0; - const doc={hidden:false,addEventListener:(n,f)=>listeners.set(n,f),removeEventListener:n=>listeners.delete(n)}; + const doc={createElement:()=>({getContext:()=>({})}),hidden:false,addEventListener:(n,f)=>listeners.set(n,f),removeEventListener:n=>listeners.delete(n)}; const motion={matches:false,addEventListener:(n,f)=>listeners.set('motion',f),removeEventListener:()=>listeners.delete('motion')}; const window={document:doc,matchMedia:()=>motion,Image:class{},fetch:async()=>({ok:false}),setTimeout:(f,d)=>{timers.set(++serial,{f,d});return serial;},clearTimeout:id=>timers.delete(id)}; runInNewContext(readFileSync(new URL('../desktop/companion-scene.js',import.meta.url),'utf8'),{window}); diff --git a/easel/test/live.test.mjs b/easel/test/live.test.mjs index d65aabc09b..4e1ae5fdd9 100644 --- a/easel/test/live.test.mjs +++ b/easel/test/live.test.mjs @@ -238,7 +238,7 @@ test("renaming and retargeting follow the piece without changing the channel", a assert.equal(live.file, join(root, "smiley.lisp")); assert.equal(live.runtime.label, "kidlisp"); assert.equal(existsSync(join(root, "movika.mjs")), false, "the untouched blank moved rather than piling up"); - assert.match(await readFile(live.file, "utf8"), /\(wipe "purple"\)/); + assert.match(await readFile(live.file, "utf8"), /\(wipe "#[0-9a-f]{6}"\)/); assert.equal(live.scanUrl, scan, "the QR stays valid across a rename"); assert.equal(live.retarget(join(root, "other.lisp")), true); diff --git a/easel/test/new-work.test.mjs b/easel/test/new-work.test.mjs index 97c103c385..27703164c0 100644 --- a/easel/test/new-work.test.mjs +++ b/easel/test/new-work.test.mjs @@ -11,7 +11,7 @@ test('fresh piece preserves old blank and revisions while replacing name, QR and const cwd=await fixture(t);const live=new LivePiece({cwd});live.create();await live.checkpoint(); const file=live.file,source=live.source(),channel=live.channel,url=live.scanUrl,history=await live.history.list(); await live.fresh();assert.notEqual(live.file,file);assert.notEqual(live.channel,channel);assert.notEqual(live.scanUrl,url); - assert.equal(await readFile(file,'utf8'),source);assert.equal(live.revision.version,1);assert.equal(live.pristine,true); + assert.equal(await readFile(file,'utf8'),source);assert.equal(live.revision.version,0);assert.equal(live.pristine,true); const old=new LivePiece({cwd,slug:file.split('/').at(-1).replace('.mjs','')});assert.deepEqual(await old.history.list(),history); live.sending=true;const next=live.file;await assert.rejects(live.fresh(),/upload/);assert.equal(live.file,next); }); diff --git a/easel/test/request-feedback.test.mjs b/easel/test/request-feedback.test.mjs index b6043ff381..8e688bf5d8 100644 --- a/easel/test/request-feedback.test.mjs +++ b/easel/test/request-feedback.test.mjs @@ -19,6 +19,6 @@ test('controls remain on the final two rows as the terminal resizes',()=>{ assert.equal(lines.length,rows); assert.equal(frameLayout(state,rows).trayStartRow,rows-4); assert.match(lines.at(-2),/hello/); - assert.match(lines.at(-1),/help/); + assert.match(lines.at(-1),/settings/); } }); diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index af0a0c8eb0..096f3c36df 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -618,17 +618,9 @@ function setUpdateAutoReload(enabled) { } } -function performHistoryRewrite(path, historical) { - // Skip history manipulation in pack mode (blob/srcdoc context) - if (checkPackMode()) return; - - // The params that describe the embedding rather than the piece — nogap, - // nolabel, autoreload — are no part of a piece's address, so the worker's - // rewrite naturally drops them. Anything that reloads the view afterwards - // then comes back as a plain page: the corner label returns, and a preview - // card starts offering updates nobody can reach it to accept. The update - // auto-reload is exactly such a reload, which is how a card that was clean - // on arrival grew its label back an hour later. +// Presentation flags belong to the view, not the piece address. Use this for +// every history write so a later browser reload boots the same embedded view. +function preserveViewParams(path) { try { const session = noPaintHistoryTarget(path, window.location.href); if (session.seed) { @@ -637,13 +629,17 @@ function performHistoryRewrite(path, historical) { } const next = new URL(path, window.location.href); for (const [name, value] of Object.entries(preservedParams || {})) { - if (value) next.searchParams.set(name, value); + if (value !== undefined && value !== null) next.searchParams.set(name, value); } - path = next.pathname + next.search + next.hash; + return next.pathname + next.search + next.hash; } catch (err) { - /* An unparseable path is rewritten as given, the way it always was. */ + return path; } +} +function performHistoryRewrite(path, historical) { + if (checkPackMode()) return; + path = preserveViewParams(path); if (historical) { console.log("Rewriting to:", path); history.pushState("", document.title, path); @@ -1016,11 +1012,11 @@ async function boot(parsed, bpm = 60, resolution, debug) { if (resolution.solo === true) preservedParams.solo = "true"; if (resolution.highlight) preservedParams.highlight = resolution.highlight === true ? "true" : resolution.highlight; - // Only preserve density/zoom/duration if they were actually in the URL (not from localStorage) + // Preserve URL-provided view options, never auth callbacks or piece arguments. const currentParams = new URLSearchParams(location.search); - if (currentParams.has("density")) preservedParams.density = currentParams.get("density"); - if (currentParams.has("zoom")) preservedParams.zoom = currentParams.get("zoom"); - if (currentParams.has("duration")) preservedParams.duration = currentParams.get("duration"); + for (const name of ["density", "zoom", "duration", "daw", "width", "height", "desktop", "noauth", "preview", "icon"]) { + if (currentParams.has(name)) preservedParams[name] = currentParams.get(name); + } if (debug) { if (window.isSecureContext) { @@ -13884,7 +13880,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { frozenUrlPath = content?.path || window.location.pathname + window.location.search + window.location.hash; try { if (!checkPackMode()) { - window.history.replaceState({}, "", frozenUrlPath); + performHistoryRewrite(frozenUrlPath, false); } } catch (e) { /* Ignore in restricted context */ @@ -14528,9 +14524,9 @@ async function boot(parsed, bpm = 60, resolution, debug) { // Use pushState instead of replaceState to preserve history navigation try { if (!content.fromHistory) { - window.history.pushState({}, "", encodedPath); + performHistoryRewrite(encodedPath, true); } else { - window.history.replaceState({}, "", encodedPath); + performHistoryRewrite(encodedPath, false); } } catch (e) { /* Ignore in restricted context */ } } else if (content.text && isKidlispSource(content.text)) { @@ -14539,9 +14535,9 @@ async function boot(parsed, bpm = 60, resolution, debug) { // Use pushState instead of replaceState to preserve history navigation try { if (!content.fromHistory) { - window.history.pushState({}, "", encodedPath); + performHistoryRewrite(encodedPath, true); } else { - window.history.replaceState({}, "", encodedPath); + performHistoryRewrite(encodedPath, false); } } catch (e) { /* Ignore in restricted context */ } } else if (content.clockShortcode) { @@ -14549,37 +14545,16 @@ async function boot(parsed, bpm = 60, resolution, debug) { const clockPath = "/" + content.clockShortcode; try { if (!content.fromHistory) { - window.history.pushState({}, "", clockPath); + performHistoryRewrite(clockPath, true); } else { - window.history.replaceState({}, "", clockPath); + performHistoryRewrite(clockPath, false); } } catch (e) { /* Ignore in restricted context */ } } else { - // For regular pieces, clear parameters but keep the basic path structure - // Preserve DAW-related params for M4L integration - const currentParams = new URLSearchParams(window.location.search); - const dawParams = new URLSearchParams(); - for (const param of ['daw', 'density', 'nogap', 'width', 'height']) { - if (currentParams.has(param)) { - dawParams.set(param, currentParams.get(param)); - } - } - // The embedding's own flags ride along too — what boot handed over - // as `preservedParams` (nolabel, autoreload, maxfps, …). This is the - // URL the update auto-reload comes back to, so a flag dropped here - // is a flag the next load never sees: a preview card that asked - // to be rid of the corner label got it back on its first reload - // exactly this way, while `nogap`, listed above, survived. - for (const [name, value] of Object.entries(preservedParams || {})) { - if (value && !dawParams.has(name)) dawParams.set(name, value); - } - const queryString = dawParams.toString(); - // Keep caret "bag" URLs literal (^pads): ^ is legal in a URL path per - // WHATWG, but the omnibox encodes it to %5E on first navigation. Decode - // it back so the address bar reads ^pads through and through. - const newUrl = (window.location.pathname + (queryString ? '?' + queryString : '')).replace(/%5[eE]/g, "^"); + // Drop piece arguments, retaining the embedding flags on every load. + const newUrl = window.location.pathname.replace(/%5[eE]/g, "^"); try { - window.history.replaceState({}, "", newUrl); + performHistoryRewrite(newUrl, false); } catch (e) { /* Ignore in restricted context */ } } } @@ -14814,11 +14789,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { urlPath = "/" + encodedText; } - history.pushState( - "", - document.title, - urlPath, // Replace "prompt" with "/". - ); + performHistoryRewrite(urlPath, true); window.parent?.postMessage( { type: "url:updated", @@ -14883,7 +14854,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { } if (!checkPackMode()) { - history.replaceState("", document.title, urlPath); + performHistoryRewrite(urlPath, false); } } catch (err) { console.warn("⚠️ Couldn't change url state. Going too fast!? ➿🚗"); @@ -15304,28 +15275,8 @@ async function boot(parsed, bpm = 60, resolution, debug) { } if (type === "refresh") { - // Reconstruct URL with preserved parameters (nogap, nolabel, duration) - const currentUrl = new URL(window.location); - - // Add preserved parameters back to the URL - if (preservedParams.nogap) { - currentUrl.searchParams.set("nogap", preservedParams.nogap); - } - if (preservedParams.nolabel) { - currentUrl.searchParams.set("nolabel", preservedParams.nolabel); - } - if (preservedParams.shellhtml) { - currentUrl.searchParams.set("shellhtml", preservedParams.shellhtml); - } - if (preservedParams.duration) { - currentUrl.searchParams.set("duration", preservedParams.duration); - } - if (preservedParams.solo) { - currentUrl.searchParams.set("solo", preservedParams.solo); - } - - // Update the URL and reload - window.location.href = currentUrl.toString(); + // Use the same contract as history rewrites, including autoreload/zoom. + window.location.href = new URL(preserveViewParams(window.location.href), window.location.href).href; return; } diff --git a/system/public/aesthetic.computer/braincell.png b/system/public/aesthetic.computer/braincell.png new file mode 100644 index 0000000000000000000000000000000000000000..b9c604971fe0eade0be771a06af385bb425dedaf GIT binary patch literal 12131 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 Qfq{X+)78&qol`;+08Q{o7ytkO literal 0 HcmV?d00001 diff --git a/system/public/aesthetic.computer/braincell.svg b/system/public/aesthetic.computer/braincell.svg new file mode 100644 index 0000000000..7d06c76b6f --- /dev/null +++ b/system/public/aesthetic.computer/braincell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/aesel-preview-navigation.test.mjs b/tests/aesel-preview-navigation.test.mjs new file mode 100644 index 0000000000..25d5f19f4d --- /dev/null +++ b/tests/aesel-preview-navigation.test.mjs @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import { noPaintHistoryTarget, NOPAINT_SESSION_SEED_KEY } from '../system/public/aesthetic.computer/lib/nopaint-navigation.mjs'; + +// Execute the actual BIOS history paths without starting its GPU/audio runtime. +const bios = readFileSync(new URL('../system/public/aesthetic.computer/bios.mjs', import.meta.url), 'utf8'); +const between = (start, end) => bios.slice(bios.indexOf(start), bios.indexOf(end, bios.indexOf(start))); +const helpers = between('function preserveViewParams(', '\nfunction resolveBackgroundFillSpec'); +const initialize = between(' preservedParams = {};', '\n if (debug) {'); +const cleanup = between(' if (!checkPackMode() && !frozenUrlPath) {', '\n // if (currentPiece !== null)'); +const navigate = between(' if ((content.pieceCount > 0 || content.alias === true)', '\n UI.spinnerReset();'); +const refresh = between(' if (type === "refresh") {', '\n if (type === "web") {'); + +function view(query = '?nogap=true&nolabel=true&autoreload=true&zoom=2&code=auth-secret&state=auth-state') { + const window = { location: new URL('https://aesthetic.computer/@jeffrey/rinara' + query), parent: { postMessage() {} } }; + const writes = []; + const history = Object.fromEntries(['pushState', 'replaceState'].map(method => [method, (_, __, path) => { + writes.push([method, path]); + window.location = new URL(path, window.location); + }])); + window.history = history; + const ctx = vm.createContext({ window, history, document: { title: '' }, URL, URLSearchParams, + noPaintHistoryTarget, NOPAINT_SESSION_SEED_KEY, sessionStorage: {setItem() {}}, console: { log() {}, warn() {} }, location: window.location, preservedParams: {}, updateAutoReload: false, + resolution: { gap: 0, nolabel: true, autoreload: true }, frozenUrlPath: null, checkPackMode: () => false, + isKidlispSource: text => text?.startsWith('('), encodeKidlispForUrl: encodeURIComponent, + }); + vm.runInContext(helpers + initialize, ctx); + const load = (content) => { ctx.content = { pieceCount: 1, fromHistory: false, ...content }; vm.runInContext(cleanup + navigate, ctx); }; + return { ctx, window, writes, load }; +} +function embedded(url) { + for (const key of ['nogap', 'nolabel', 'autoreload']) assert.equal(url.searchParams.get(key), 'true', key); + assert.equal(url.searchParams.get('zoom'), '2'); + assert.equal(url.searchParams.has('code'), false); + assert.equal(url.searchParams.has('state'), false); +} + +test('owned-piece load, source push, browser history and reload retain view flags', () => { + const v = view(); + for (const fromHistory of [false, true, false]) { + v.load({ text: '@jeffrey/rinara', path: 'media/@jeffrey/piece/rinara', fromHistory }); + embedded(v.window.location); + assert.equal(v.window.location.pathname, '/@jeffrey/rinara'); + // A browser reload boots exactly this URL, with owned route for channel resubscription. + embedded(new URL(v.window.location.href)); + } +}); + +test('Kidlisp, clock, prompt and painting navigation preserve view flags and target hash', () => { + for (const content of [ + { text: '(wipe red)' }, + { path: 'aesthetic.computer/disks/prompt', params: ['(wipe red)'], text: 'prompt~(wipe red)' }, + { text: '*wibe', clockShortcode: '*wibe' }, + { text: '/prompt' }, + { text: 'painting', path: 'aesthetic.computer/disks/painting', hash: 'abc123' }, + ]) { + const v = view(); v.load(content); embedded(v.window.location); + if (content.hash) assert.equal(v.window.location.hash, '#abc123'); + } +}); + +test('explicit worker refresh restores all flags even after another caller stripped them', () => { + const v = view(); + v.window.location = new URL('https://aesthetic.computer/@jeffrey/rinara#target'); + v.ctx.type = 'refresh'; + vm.runInContext('(function(){' + refresh + '})()', v.ctx); + embedded(v.window.location); + assert.equal(v.window.location.hash, '#target'); +}); + +test('DAW dimensions and boolean empty parameters survive cleanup; pack views never write history', () => { + const v = view('?daw&width=320&height=200&density=0&noauth&preview&icon'); + v.load({ text: '@jeffrey/rinara' }); + for (const key of ['daw', 'noauth', 'preview', 'icon']) assert.equal(v.window.location.searchParams.get(key), ''); + assert.equal(v.window.location.searchParams.get('density'), '0'); + assert.equal(v.window.location.searchParams.get('width'), '320'); + assert.equal(v.window.location.searchParams.get('height'), '200'); + v.ctx.checkPackMode = () => true; + const count = v.writes.length; + v.load({ text: '@jeffrey/other' }); + assert.equal(v.writes.length, count); +});