From 9596c89a2b915f393df2c571d8c5ba5da6afd78e Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 17 Sep 2026 19:32:19 -0700 Subject: [PATCH 1/2] Split Aesel dev and release channels with automatic fleet source sync --- easel/bin/easel-desktop | 7 ++- easel/desktop/build-status.cjs | 30 +++++++++ easel/desktop/credit-label.cjs | 2 +- easel/desktop/donkey.css | 2 + easel/desktop/main.cjs | 35 +++++++---- easel/desktop/native-title.cjs | 2 +- easel/desktop/preload.cjs | 2 + easel/desktop/renderer.js | 6 +- easel/desktop/test/build-status.test.cjs | 6 ++ easel/desktop/updater.cjs | 12 ++-- easel/dev/README.md | 38 ++++++++++++ easel/dev/install.mjs | 57 ++++++++++++++++++ easel/dev/sync.mjs | 77 ++++++++++++++++++++++++ easel/test/dev-sync.test.mjs | 16 +++++ 14 files changed, 269 insertions(+), 23 deletions(-) create mode 100644 easel/desktop/build-status.cjs create mode 100644 easel/desktop/test/build-status.test.cjs create mode 100644 easel/dev/README.md create mode 100644 easel/dev/install.mjs create mode 100644 easel/dev/sync.mjs create mode 100644 easel/test/dev-sync.test.mjs diff --git a/easel/bin/easel-desktop b/easel/bin/easel-desktop index c5485bc924..e0e9354205 100755 --- a/easel/bin/easel-desktop +++ b/easel/bin/easel-desktop @@ -6,7 +6,7 @@ usage() { cat <<'HELP' Usage: aesel [directory] [--backend ac|claude|codex] [--model NAME] [--piece FILE] [--resume THREAD_ID] - [--no-autopublish] + [--no-autopublish] [--dev] aesel --cwd DIRECTORY [options] ac [directory] [options] @@ -21,9 +21,11 @@ HELP } fail() { printf 'aesel: %s\n' "$*" >&2; exit 1; } workspace='' +dev=false arguments=() while (($#)); do case "$1" in + --dev) dev=true; shift ;; -h|--help) usage; exit 0 ;; --cwd) (($# >= 2)) && [[ -n "$2" ]] || fail '--cwd needs a directory' @@ -50,8 +52,9 @@ while (($#)); do done [[ "$(uname -s)" == Darwin ]] || fail 'the standalone launcher currently requires macOS; use ac for the terminal interface' app_path="${AESEL_DESKTOP_APP:-${EASEL_DESKTOP_APP:-$HOME/Applications/aesel.app}}" +if $dev; then app_path="$HOME/Applications/Aesel Dev.app"; fi # Existing installations keep working until the renamed app is installed. -if [[ ! -d "$app_path" && -z "${AESEL_DESKTOP_APP:-}${EASEL_DESKTOP_APP:-}" && -d "$HOME/Applications/Easel.app" ]]; then +if ! $dev && [[ ! -d "$app_path" && -z "${AESEL_DESKTOP_APP:-}${EASEL_DESKTOP_APP:-}" && -d "$HOME/Applications/Easel.app" ]]; then app_path="$HOME/Applications/Easel.app" fi [[ -d "$app_path" ]] || fail "aesel.app is not installed at $app_path. Install the standalone app or run ac." diff --git a/easel/desktop/build-status.cjs b/easel/desktop/build-status.cjs new file mode 100644 index 0000000000..7646e5c809 --- /dev/null +++ b/easel/desktop/build-status.cjs @@ -0,0 +1,30 @@ +const fs=require('node:fs'),path=require('node:path'); +function devStatus(running,state){ + const fresh=state?.checkedAt&&Date.now()-Date.parse(state.checkedAt)<180000; + let status=!state?.online||!fresh?'unknown':state.state; + if(state?.modified?.length)status='modified'; + else if(state?.online&&fresh&&state.installed?.tree!==running.tree)status='ready'; + else if(state?.online&&fresh&&state.target?.tree===running.tree)status='current'; + return {channel:'dev',version:running.version,revision:running.revision,tree:running.tree,status,latest:state?.target?.revision,checkedAt:state?.checkedAt||null}; +} +function createBuildStatus({app,devHome,root,send,onDevReady,channel=app.isPackaged?'release':'local'}){ + let running=devHome?JSON.parse(fs.readFileSync(path.join(root,'../build.json'))):null; + let latest={channel,version:app.getVersion(),status:'unknown'}; + let requested='',timer; + const emit=()=>send('build-status',latest); + function poll(){ + if(!devHome)return emit(); + try{ + const state=JSON.parse(fs.readFileSync(path.join(devHome,'status.json')));latest=devStatus(running,state);emit(); + if(latest.status==='ready'&&requested!==state.installed.tree){ + const target=fs.realpathSync(path.join(devHome,'current')); + const next=JSON.parse(fs.readFileSync(path.join(target,'build.json'))); + if(next.tree!==state.installed.tree)return; + requested=next.tree;onDevReady(path.join(target,'easel'),{sameHost:next.host===running.host,sameUI:next.ui===running.ui}); + } + }catch{latest={...latest,status:'unknown'};emit();} + } + if(devHome){timer=setInterval(poll,3000);timer.unref();} + return {poll,emit,release(status,info={}){if(!devHome){latest={...latest,status,latest:info.version||latest.latest,checkedAt:new Date().toISOString()};emit();}},adopt(nextRoot){running=JSON.parse(fs.readFileSync(path.join(nextRoot,'../build.json')));requested='';poll();},close(){clearInterval(timer);}}; +} +module.exports={devStatus,createBuildStatus}; diff --git a/easel/desktop/credit-label.cjs b/easel/desktop/credit-label.cjs index 9a0d375477..ad22d251d2 100644 --- a/easel/desktop/credit-label.cjs +++ b/easel/desktop/credit-label.cjs @@ -2,7 +2,7 @@ const {execFile} = require('node:child_process'); const {join} = require('node:path'); const {pathToFileURL} = require('node:url'); -function startCreditLabel({app, window, root, nativeRoot = app.isPackaged ? join(process.resourcesPath,'app.asar.unpacked') : __dirname}) { +function startCreditLabel({app, window, root, nativeRoot = process.env.AESEL_DEV_ROOT ? join(process.env.AESEL_DEV_ROOT,'desktop') : app.isPackaged ? join(process.resourcesPath,'app.asar.unpacked') : __dirname}) { let timer, disposed = false, busy = false, lastText = '', lastPayload, offer = null; let sessionPromise; const session = () => sessionPromise ||= import(pathToFileURL(join(root,'src/ac-session.mjs')).href).then(({ACSession})=>new ACSession()); diff --git a/easel/desktop/donkey.css b/easel/desktop/donkey.css index c5940edb15..94fb9468e0 100644 --- a/easel/desktop/donkey.css +++ b/easel/desktop/donkey.css @@ -302,3 +302,5 @@ body:not(.preview-fullscreen) #artifact-shell{box-shadow:1px 1px 0 #282130,3px 3 #notebook-thinking-donkey{opacity:1;filter:none} #prompt-feedback{width:calc(var(--donkey-size)*512/416 + 16px)} + +#provider-menu .build-status{font-size:12px;opacity:.8;overflow-wrap:anywhere}#provider-menu .build-status[data-status=ready],#provider-menu .build-status[data-status=modified]{color:var(--aesel-accent);opacity:1} diff --git a/easel/desktop/main.cjs b/easel/desktop/main.cjs index 8a376a4fcf..2daba52ba8 100644 --- a/easel/desktop/main.cjs +++ b/easel/desktop/main.cjs @@ -11,8 +11,9 @@ const {createUpdater} = require('./updater.cjs'); const {startFrameCapture} = require('./frame-capture.cjs'); const { tmpdir, homedir } = require('node:os'); -app.setName('aesel'); -app.setPath('userData', join(app.getPath('appData'), 'Easel')); +const devHome=process.env.AESEL_DEV_HOME||''; +app.setName(devHome?'Aesel Dev':'aesel'); +app.setPath('userData', join(app.getPath('appData'), devHome?'Aesel Dev':'Easel')); let window, terminal, timer, quitting = false; let lastVisibleState = null, keepPreviewOnStart = false; let terminalSize = {cols:100,rows:32}; @@ -35,7 +36,7 @@ try { systemTextWatcher = watch(join(homedir(), '.local', 'share', 'slab', 'stat 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, '..'); +let root = process.env.AESEL_DEV_ROOT || (app.isPackaged ? join(process.resourcesPath, 'easel') : resolve(__dirname, '..')); const qrEncoder = import(require('node:url').pathToFileURL(join(root, 'src/vendor/qr.mjs')).href); const slabHome = join(tmpdir(), `easel-desktop-${process.pid}`); const supplied = process.argv.slice(app.isPackaged ? 1 : 2); @@ -125,8 +126,19 @@ function requestRestart(action = 'restart') { writeFileSync(intentFile, JSON.stringify({action}), {mode:0o600}); terminal.kill('SIGUSR2'); } -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)}); +const canUpdateBinary = !devHome && app.isPackaged && !process.mas && existsSync(join(process.resourcesPath,'app-update.yml')); +const desktopUpdater = createUpdater({app, canUpdateBinary, onStatus:(status,info)=>buildStatus.release(status,info), requestRestart, prepareRelaunch: () => writeFileSync(continuationFile,JSON.stringify({cwd:workspace,at:Date.now()}),{mode:0o600}), notify: message => send('desktop-notice', message)}); + +let pendingDevRoot=null,pendingDevUI=false; +const buildStatus=require('./build-status.cjs').createBuildStatus({app,devHome,root,send,channel:canUpdateBinary||process.mas?'release':'local',onDevReady:(next,compatibility)=>{ + pendingDevRoot=next;pendingDevUI=!compatibility.sameUI; + requestRestart(compatibility.sameHost?'restart':'update'); +}}); +function checkBuildUpdates(){ + if(devHome){const child=spawn(process.execPath,[join(devHome,'sync-runner.mjs')],{env:{...process.env,ELECTRON_RUN_AS_NODE:'1'},stdio:'ignore'});child.on('error',()=>buildStatus.poll());child.on('exit',()=>buildStatus.poll());} + else void desktopUpdater.check(); +} +ipcMain.on('check-build-updates',event=>{if(event.sender===window?.webContents)checkBuildUpdates();}); function send(channel, data) { if (window && !window.isDestroyed()) window.webContents.send(channel, data); } function start() { @@ -143,7 +155,7 @@ function start() { try { // Native extraction can discard mode bits; repair only our own launcher. if(process.platform!=='win32'&&!process.mas){ - const helper=join(app.isPackaged?join(process.resourcesPath,'app.asar.unpacked'):__dirname,'node_modules/node-pty/build/Release/spawn-helper'); + const helper=join(devHome?join(root,'desktop'):app.isPackaged?join(process.resourcesPath,'app.asar.unpacked'):__dirname,'node_modules/node-pty/build/Release/spawn-helper'); if(existsSync(helper))chmodSync(helper,0o755); } terminal = process.mas @@ -159,6 +171,7 @@ function start() { pendingRestart = false; continueSession = true; keepPreviewOnStart = true; timer?.close(); send('desktop-notice','Agent restarted. Your window and preview stay open.'); + if(pendingDevRoot){root=pendingDevRoot;pendingDevRoot=null;buildStatus.adopt(root);if(pendingDevUI){pendingDevUI=false;window.loadFile(join(root,'desktop/index.html'));return;}} start(); return; } if (request.action === 'update') { @@ -221,7 +234,7 @@ app.whenReady().then(() => { Menu.setApplicationMenu(Menu.buildFromTemplate([ { label: 'Aesel', submenu: [ {role:'about'}, {type:'separator'}, {role:'close',accelerator:'CmdOrCtrl+W'}, - {label:'Check for Updates…', click:()=>desktopUpdater.check()}, + {label:'Check for Updates…', click:checkBuildUpdates}, {label:'Restart Agent', click:()=>requestRestart('restart')}, {label:'Reload Interface', click:()=>window.webContents.reload()}, {label:'Restart App', click:()=>requestRestart('update')}, @@ -247,7 +260,7 @@ app.whenReady().then(() => { ] }, { role: 'windowMenu' }, ])); - window = new BrowserWindow({ width: 760, height: 540, title: 'aesel', backgroundColor: '#463264', + window = new BrowserWindow({ width: 760, height: 540, title: devHome?'Aesel Dev':'aesel', backgroundColor: '#463264', webPreferences: { preload: join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true, webviewTag: true, plugins:true } }); const creditLabel = require('./credit-label.cjs').startCreditLabel({app, window, root}); require('./credit-checkout.cjs').startCreditCheckout({app,window,root,ipcMain,shell,refresh:creditLabel.refresh}); @@ -296,7 +309,7 @@ app.whenReady().then(() => { screen.on('display-metrics-changed', sendDisplay); window.on('enter-full-screen', fullscreenState); window.on('leave-full-screen', () => { previewFullscreen = false; fullscreenState(); }); - const uiRoot = existsSync(join(root,'desktop-ui','index.html')) ? join(root,'desktop-ui') : __dirname; + const uiRoot = devHome ? join(root,'desktop') : existsSync(join(root,'desktop-ui','index.html')) ? join(root,'desktop-ui') : __dirname; window.loadFile(join(uiRoot, 'index.html')); if (canUpdateBinary) { const firstCheck = setTimeout(() => desktopUpdater.check(), 30000); firstCheck.unref(); @@ -305,7 +318,7 @@ 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(); } } }); }); -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(); } }); +ipcMain.on('ready', event => { if (event.sender === window?.webContents) { send('theme',currentTheme); buildStatus.poll(); 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(); }); @@ -313,7 +326,7 @@ ipcMain.on('input', (event, data) => { if (event.sender === window?.webContents 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', () => { timer?.close(); stopFrameCapture(); themeFollower.close(); terminal?.kill(); app.quit(); }); +app.on('window-all-closed', () => { buildStatus.close();systemTextWatcher?.close(); 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); }); diff --git a/easel/desktop/native-title.cjs b/easel/desktop/native-title.cjs index 2338aa5389..61e67043fa 100644 --- a/easel/desktop/native-title.cjs +++ b/easel/desktop/native-title.cjs @@ -11,7 +11,7 @@ function registerNativeTitle({ipcMain, window, app}) { // Bound helper concurrency when titles change rapidly. if (pending) await pending; if (cache.has(key)) return cache.get(key); - const nativeRoot = app.isPackaged ? join(process.resourcesPath,'app.asar.unpacked') : __dirname; + const nativeRoot = process.env.AESEL_DEV_ROOT ? join(process.env.AESEL_DEV_ROOT,'desktop') : app.isPackaged ? join(process.resourcesPath,'app.asar.unpacked') : __dirname; pending = new Promise(resolve => execFile(join(nativeRoot,'native','credit-label'),[value.text,String(size),'--glyphs'],{encoding:'buffer',timeout:5000,maxBuffer:2*1024*1024},(error,data)=>{try{resolve(error?null:JSON.parse(data.toString('utf8')));}catch{resolve(null);}})); const image = await pending; pending = null; diff --git a/easel/desktop/preload.cjs b/easel/desktop/preload.cjs index e59d161a44..86d046b672 100644 --- a/easel/desktop/preload.cjs +++ b/easel/desktop/preload.cjs @@ -7,6 +7,8 @@ contextBridge.exposeInMainWorld('aesel', { input: data => { if (typeof data === 'string') ipcRenderer.send('input', data); }, size: (cols, rows) => ipcRenderer.send('size', { cols, rows }), titleGeometry: value => ipcRenderer.send('title-geometry',value), + onBuildStatus: fn => ipcRenderer.on('build-status',(_event,value)=>{if(value&&['dev','release','local'].includes(value.channel))fn(value);}), + checkBuildUpdates: () => ipcRenderer.send('check-build-updates'), ready: () => ipcRenderer.send('ready'), closing: () => ipcRenderer.send('closing'), fullscreen: target => { diff --git a/easel/desktop/renderer.js b/easel/desktop/renderer.js index 207b7b5be3..948242cf93 100644 --- a/easel/desktop/renderer.js +++ b/easel/desktop/renderer.js @@ -614,7 +614,7 @@ boot().catch(error => { document.getElementById('terminal').textContent = `Could const menu=document.createElement('dialog');menu.id='provider-menu';menu.setAttribute('aria-label','Settings');menu.hidden=true; document.body.append(footer,menu); window.installProxHover(footer,document.getElementById('version')); - let provider=null,credits=null,generation=0,lastText='',menuKey=''; + let provider=null,credits=null,buildInfo=null,generation=0,lastText='',menuKey=''; const balance=()=>{ if(!credits)return null; if(Number.isFinite(credits.remaining)&&Number.isFinite(credits.purchased))return credits.remaining+credits.purchased; @@ -630,12 +630,13 @@ boot().catch(error => { document.getElementById('terminal').textContent = `Could button.addEventListener('click',action);menu.append(button);return button; } function rebuildMenu(){ - const nextKey=JSON.stringify([provider,credits]);if(nextKey===menuKey)return;menuKey=nextKey; + const nextKey=JSON.stringify([provider,credits,buildInfo]);if(nextKey===menuKey)return;menuKey=nextKey; const focused=document.activeElement?.textContent; const scroll=menu.scrollTop;const previewHost=menu.querySelector('.history-preview-host');const historyOpen=menu.querySelector('details')?.open??true; menu.replaceChildren();const n=balance(); const header=document.createElement('header');const title=document.createElement('h2');title.textContent='Settings'; const dismiss=document.createElement('button');dismiss.type='button';dismiss.textContent='×';dismiss.setAttribute('aria-label','Close settings');dismiss.addEventListener('click',close);header.append(title,dismiss);menu.append(header); + if(buildInfo){const status=document.createElement('p');status.className='build-status';status.dataset.status=buildInfo.status;const channel=buildInfo.channel==='dev'?'Dev':buildInfo.channel==='release'?'Release':'Local';const state={current:'Up to date',ready:'Update ready · saving session',modified:'Local changes · sync paused',checking:'Checking…',syncing:'Downloading dev build…',downloading:'Downloading update…',unknown:'Unable to verify'}[buildInfo.status]||'Unable to verify';status.textContent=[channel,buildInfo.version,buildInfo.revision?.slice(0,8),state].filter(Boolean).join(' · ');menu.append(status);item('Check for updates',()=>window.aesel.checkBuildUpdates());} const current=document.createElement('p');current.textContent=[provider?.backend==='ac'?'AC':provider?.backend==='codex'?'Codex':'Claude',provider?.model].filter(Boolean).join(' · ');menu.append(current); for(const [index,id,name] of [[0,'ac','AC'],[1,'claude','Claude'],[2,'codex','Codex']]){ @@ -691,6 +692,7 @@ boot().catch(error => { document.getElementById('terminal').textContent = `Could footer.append(document.getElementById('version')); } window.updateProviderFooter=value=>{if(!['ac','claude','codex'].includes(value?.backend))return;provider=value;window.updateNotebookBindings?.(value);render();}; + window.aesel.onBuildStatus?.(value=>{buildInfo=value;render();}); window.aesel.onCredits(value=>{credits=value;render();}); footer.addEventListener('click',()=>{if(menu.hidden){menu.hidden=false;menu.showModal();window.aesel.input('\x1b[99;5~');footer.setAttribute('aria-expanded','true');menu.querySelector('button:not(:disabled)')?.focus();}else close();}); document.addEventListener('pointerdown',event=>{if(!footer.contains(event.target)&&!menu.contains(event.target))close();}); diff --git a/easel/desktop/test/build-status.test.cjs b/easel/desktop/test/build-status.test.cjs new file mode 100644 index 0000000000..77b8e410fa --- /dev/null +++ b/easel/desktop/test/build-status.test.cjs @@ -0,0 +1,6 @@ +const {test}=require('node:test');const assert=require('node:assert/strict');const {devStatus}=require('../build-status.cjs'); +const running={tree:'a',revision:'123456789',version:'0.7.3'}; +const state={online:true,state:'current',checkedAt:new Date().toISOString(),target:{tree:'a'},installed:{tree:'a'}}; +test('dev compares source trees rather than app semver',()=>{assert.equal(devStatus(running,state).status,'current');assert.equal(devStatus(running,{...state,target:{tree:'b'},installed:{tree:'b'}}).status,'ready');}); +test('offline and stale checks never claim up to date',()=>{assert.equal(devStatus(running,{...state,online:false}).status,'unknown');assert.equal(devStatus(running,{...state,checkedAt:'2000-01-01'}).status,'unknown');}); +test('local edits outrank a staged update',()=>{assert.equal(devStatus(running,{...state,modified:['src/tui.mjs'],installed:{tree:'b'}}).status,'modified');}); diff --git a/easel/desktop/updater.cjs b/easel/desktop/updater.cjs index 769b796def..4ad8534120 100644 --- a/easel/desktop/updater.cjs +++ b/easel/desktop/updater.cjs @@ -1,21 +1,21 @@ // Binary updates are owned by the desktop host, never by the CLI tar updater. -function createUpdater({app, notify, requestRestart, prepareRelaunch = () => {}, canUpdateBinary = app.isPackaged, load = () => require('electron-updater').autoUpdater}) { +function createUpdater({app, notify, requestRestart, prepareRelaunch = () => {}, onStatus = () => {}, canUpdateBinary = app.isPackaged, load = () => require('electron-updater').autoUpdater}) { let updater, downloaded = false, applying = false; function configure() { if (updater || !canUpdateBinary) return updater; updater = load(); updater.autoDownload = true; updater.autoInstallOnAppQuit = false; // Session checkpoint must succeed first. - updater.on('error', error => notify(`Update failed: ${error.message}`)); - updater.on('update-not-available', () => notify('aesel is up to date.')); - updater.on('update-available', info => notify(`Downloading aesel ${info.version}…`)); - updater.on('update-downloaded', () => { downloaded = true; if (!applying) requestRestart('update'); }); + updater.on('error', error => { onStatus('unknown'); notify(`Update failed: ${error.message}`); }); + updater.on('update-not-available', info => { onStatus('current',info); notify('aesel is up to date.'); }); + updater.on('update-available', info => { onStatus('downloading',info); notify(`Downloading aesel ${info.version}…`); }); + updater.on('update-downloaded', info => { onStatus('ready',info); downloaded = true; if (!applying) requestRestart('update'); }); return updater; } return { async check() { if (!canUpdateBinary) { notify('Reloading this development build…'); requestRestart('restart'); return; } - try { await configure().checkForUpdates(); } catch (error) { notify(`Update failed: ${error.message}`); } + try { onStatus('checking'); await configure().checkForUpdates(); } catch (error) { onStatus('unknown'); notify(`Update failed: ${error.message}`); } }, async afterCheckpoint(action) { if (applying) return; diff --git a/easel/dev/README.md b/easel/dev/README.md new file mode 100644 index 0000000000..b6716a3eff --- /dev/null +++ b/easel/dev/README.md @@ -0,0 +1,38 @@ +# Aesel Dev + +`Aesel Dev.app` and `aesel-dev` follow the committed `easel/` tree on monorepo +`origin/main`. `aesel --dev` opens the same app. Ordinary `aesel` opens the signed +release and follows its existing release feed. They use separate bundle IDs, +application data, windows, and update channels. Account credentials remain with +the existing AC/provider login stores. + +Both fleet machines check main once a minute. A change outside `easel/` does not +create a dev build. New source is extracted to an immutable directory, native +helpers are compiled, and `current` moves only after staging succeeds. Runtime +changes checkpoint and restart the agent in its existing window. UI changes +also reload the interface. Host changes checkpoint and relaunch the Dev app. +Active requests finish before the checkpoint. Project files are never synced +between machines, and working Git checkouts are never reset. + +Settings shows Dev/Release/Local, app version, dev revision, and update state. +Piece versions such as v3 are separate. Offline or old checks say they cannot +verify freshness. Local edits inside an installed dev snapshot pause syncing +and show “Local changes”; move them into a source checkout before republishing. +An offline machine catches up when it reconnects. “Lockstep” means the same +committed source tree once both machines are online and have finished their +current requests; it does not claim simultaneous swaps or copy uncommitted work. + +Install once on each Mac using matching Electron and production dependencies: + +```sh +node easel/dev/install.mjs --repo ~/aesthetic-computer \ + --base ~/Applications/aesel.app --dependencies /path/to/extracted/node_modules +``` + +The installer creates `~/Applications/Aesel Dev.app`, `~/.local/bin/aesel-dev`, +and the launch agent `computer.aesthetic.aesel-dev-sync`. State, logs, immutable +source snapshots, and the atomic `current` link live in +`~/.local/share/aesel-dev/`. The native Electron shell is not replaced by source +sync. If Electron or npm dependency requirements change, syncing stops visibly +until this installer is rerun with matching dependencies. No signing credentials +are copied; the development shell is signed locally with an ad-hoc identity. diff --git a/easel/dev/install.mjs b/easel/dev/install.mjs new file mode 100644 index 0000000000..825870dacc --- /dev/null +++ b/easel/dev/install.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import {execFileSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import {dependencyKey} from './sync.mjs'; +const option=(name,fallback)=>{const at=process.argv.indexOf(name);return at<0?fallback:process.argv[at+1];}; +const source=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); +const home=path.join(os.homedir(),'.local/share/aesel-dev'); +const repo=option('--repo',path.join(os.homedir(),'aesthetic-computer')); +const base=option('--base',path.join(os.homedir(),'Applications/aesel.app')); +const deps=option('--dependencies',path.join(source,'desktop/node_modules')); +const app=path.join(os.homedir(),'Applications/Aesel Dev.app'); +const pkg=JSON.parse(fs.readFileSync(path.join(source,'desktop/package.json'))); +if(process.platform!=='darwin')throw Error('The fleet dev installer currently supports macOS.'); +if(!fs.existsSync(base)||!fs.existsSync(deps))throw Error('Provide an installed Electron app and its extracted production node_modules.'); +fs.mkdirSync(home,{recursive:true});fs.mkdirSync(path.join(home,'dependencies'),{recursive:true}); +// Copy production Electron dependencies, including the matching node-pty binary. +execFileSync('/usr/bin/rsync',['-a',deps+'/',path.join(home,'dependencies/node_modules/')]); +const dependencyTarget=path.join(home,'dependencies/node_modules'); +const helper=path.join(dependencyTarget,'node-pty/build/Release/spawn-helper');if(fs.existsSync(helper))fs.chmodSync(helper,0o755); +const executable=execFileSync('/usr/libexec/PlistBuddy',['-c','Print:CFBundleExecutable',path.join(base,'Contents/Info.plist')],{encoding:'utf8'}).trim(); +const electron=path.join(base,'Contents/MacOS',executable); +const actual=execFileSync(electron,['-p','process.versions.electron'],{encoding:'utf8',env:{...process.env,ELECTRON_RUN_AS_NODE:'1'}}).trim(); +if(actual!==pkg.devDependencies.electron)throw Error(`Electron ${pkg.devDependencies.electron} required; base has ${actual}.`); +execFileSync(electron,['-e',`require(${JSON.stringify(path.join(dependencyTarget,'node-pty'))})`],{env:{...process.env,ELECTRON_RUN_AS_NODE:'1'}}); +fs.writeFileSync(path.join(home,'config.json'),JSON.stringify({repo,dependencyKey:dependencyKey(pkg)},null,2)); +fs.copyFileSync(path.join(source,'dev/sync.mjs'),path.join(home,'sync-bootstrap.mjs')); +fs.writeFileSync(path.join(home,'sync-runner.mjs'),`import fs from 'node:fs';import {pathToFileURL} from 'node:url';const home=${JSON.stringify(home)};const current=home+'/current/easel/dev/sync.mjs';try{const module=await import(pathToFileURL(fs.existsSync(current)?current:home+'/sync-bootstrap.mjs'));console.log(JSON.stringify(module.sync({home})));}catch(error){console.error(error.message.split('\\n')[0]);process.exitCode=1;}\n`); +execFileSync(process.execPath,[path.join(home,'sync-runner.mjs')],{stdio:'inherit',timeout:240000}); +const stage=app+`.stage-${process.pid}.app`; +execFileSync('/bin/cp',['-cR',base,stage]); +const resources=path.join(stage,'Contents/Resources'); +for(const name of ['app.asar','app.asar.unpacked','easel','app-update.yml'])fs.rmSync(path.join(resources,name),{recursive:true,force:true}); +fs.mkdirSync(path.join(resources,'app'),{recursive:true}); +fs.writeFileSync(path.join(resources,'app/package.json'),JSON.stringify({name:'aesel-dev',version:pkg.version,main:'main.cjs'})); +fs.writeFileSync(path.join(resources,'app/main.cjs'),`const fs=require('node:fs'),path=require('node:path');const home=path.join(require('node:os').homedir(),'.local/share/aesel-dev');const root=path.join(fs.realpathSync(path.join(home,'current')),'easel');process.env.AESEL_DEV_HOME=home;process.env.AESEL_DEV_ROOT=root;require(path.join(root,'desktop/main.cjs'));\n`); +for(const [key,value] of Object.entries({CFBundleIdentifier:'computer.aesthetic.aesel.dev',CFBundleName:'Aesel Dev',CFBundleDisplayName:'Aesel Dev'})){ + try{execFileSync('/usr/libexec/PlistBuddy',['-c',`Set :${key} ${value}`,path.join(stage,'Contents/Info.plist')],{stdio:'pipe'});}catch{execFileSync('/usr/libexec/PlistBuddy',['-c',`Add :${key} string ${value}`,path.join(stage,'Contents/Info.plist')]);} +} +execFileSync('/usr/bin/codesign',['--force','--deep','--sign','-','--preserve-metadata=entitlements,requirements,flags',stage],{stdio:'inherit',timeout:120000}); +if(fs.existsSync(app)){ + const live=execFileSync('/bin/ps',['-axo','command='],{encoding:'utf8'}).split('\n').some(line=>line.startsWith(path.join(app,'Contents/MacOS')+'/')); + if(live)throw Error('Existing Dev app is running; staged shell kept. Quit Dev before replacing its Electron shell.'); + fs.renameSync(app,path.join(home,`previous-shell-${Date.now()}.app`)); +} +fs.renameSync(stage,app); +const bin=path.join(os.homedir(),'.local/bin');fs.mkdirSync(bin,{recursive:true}); +fs.writeFileSync(path.join(bin,'aesel-dev'),`#!/bin/bash\nexec "$HOME/.local/share/aesel-dev/current/easel/bin/easel-desktop" --dev "$@"\n`,{mode:0o755}); +const xml=s=>s.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>'); +const plist=path.join(os.homedir(),'Library/LaunchAgents/computer.aesthetic.aesel-dev-sync.plist'); +fs.mkdirSync(path.dirname(plist),{recursive:true}); +fs.writeFileSync(plist,`Labelcomputer.aesthetic.aesel-dev-syncProgramArguments${xml(fs.realpathSync(process.execPath))}${xml(path.join(home,'sync-runner.mjs'))}RunAtLoadStartInterval60ProcessTypeBackgroundNice10LowPriorityIOEnvironmentVariablesPATH/Users/jas/.local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbinStandardOutPath${xml(path.join(home,'sync.log'))}StandardErrorPath${xml(path.join(home,'sync-error.log'))}`); +try{execFileSync('/bin/launchctl',['bootout',`gui/${process.getuid()}`,plist],{stdio:'ignore'});}catch{} +execFileSync('/bin/launchctl',['bootstrap',`gui/${process.getuid()}`,plist]); +console.log('Installed Aesel Dev. It follows committed origin/main; release Aesel is separate.'); diff --git a/easel/dev/sync.mjs b/easel/dev/sync.mjs new file mode 100644 index 0000000000..6217ffc410 --- /dev/null +++ b/easel/dev/sync.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +// The shared dev channel is the committed easel tree on origin/main. +// Never checks out, resets, or edits a developer's working tree. +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import crypto from 'node:crypto'; +import {execFileSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +export const digest = value => crypto.createHash('sha256').update(value).digest('hex'); +export function fingerprints(directory) { + const files = {}; + function visit(relative = '') { + for (const entry of fs.readdirSync(path.join(directory, relative), {withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name))) { + if (['node_modules','.git','dist','.build','.DS_Store'].includes(entry.name)) continue; + const name = path.join(relative,entry.name); + if (entry.isDirectory()) visit(name); + else if(entry.isSymbolicLink()) files[name]=digest('symlink:'+fs.readlinkSync(path.join(directory,name))); + else if (entry.isFile() && !['native/gamepad','desktop/native/credit-label'].includes(name)) files[name] = digest(fs.readFileSync(path.join(directory,name))); + } + } + visit(); return files; +} +export function modifiedFiles(directory, manifest) { + const actual=fingerprints(directory),expected=manifest.files; + return [...new Set([...Object.keys(actual),...Object.keys(expected)])].filter(name=>actual[name]!==expected[name]); +} +export function dependencyKey(pkg) { return digest(JSON.stringify({dependencies:pkg.dependencies,electron:pkg.devDependencies?.electron})); } +function writeJSON(file,value){const temp=file+'.tmp';fs.writeFileSync(temp,JSON.stringify(value,null,2)+'\n');fs.renameSync(temp,file);} +export function sync({home=path.join(os.homedir(),'.local/share/aesel-dev'),fetch=true,build=source=>execFileSync('/bin/bash',[path.join(source,'desktop/scripts/build-credit-label.sh')],{timeout:120000,stdio:['ignore','pipe','pipe']})}={}) { + const config=JSON.parse(fs.readFileSync(path.join(home,'config.json'))); + const stateFile=path.join(home,'status.json'),lock=path.join(home,'sync.lock'); + fs.mkdirSync(home,{recursive:true}); + try{fs.mkdirSync(lock);}catch{try{const pid=Number(fs.readFileSync(path.join(lock,'pid'),'utf8'));process.kill(pid,0);}catch(error){if(error.code==='ESRCH')fs.rmSync(lock,{recursive:true,force:true});}return {state:'busy'};} + fs.writeFileSync(path.join(lock,'pid'),String(process.pid)); + let previous={};try{previous=JSON.parse(fs.readFileSync(stateFile));}catch{} + const git=(...args)=>execFileSync('/usr/bin/git',['-C',config.repo,...args],{encoding:'utf8',timeout:120000,stdio:['ignore','pipe','pipe'],env:{...process.env,GIT_TERMINAL_PROMPT:'0'}}).trim(); + try { + if(fetch)git('fetch','origin','main','--quiet'); + const revision=git('rev-parse','refs/remotes/origin/main'),tree=git('rev-parse',`${revision}:easel`); + const target=path.join(home,'versions',tree),current=path.join(home,'current'); + let installed;try{installed=JSON.parse(fs.readFileSync(path.join(current,'build.json')));}catch{} + const changed=installed?modifiedFiles(path.join(current,'easel'),installed):[]; + const state={channel:'dev',target:{revision,tree},checkedAt:new Date().toISOString(),online:true,installed:installed&&{revision:installed.revision,tree:installed.tree},modified:changed,state:changed.length?'modified':'checking'}; + if(changed.length){writeJSON(stateFile,state);return state;} + if(installed?.tree!==tree){ + state.state='syncing';writeJSON(stateFile,state); + if(!fs.existsSync(path.join(target,'build.json'))){ + const stage=target+`.stage-${process.pid}`;fs.mkdirSync(stage,{recursive:true}); + try { + execFileSync('/usr/bin/git',['-C',config.repo,'archive','--format=tar','-o',path.join(stage,'source.tar'),revision,'easel'],{timeout:120000}); + execFileSync('/usr/bin/tar',['-xf',path.join(stage,'source.tar'),'-C',stage]);fs.unlinkSync(path.join(stage,'source.tar')); + const source=path.join(stage,'easel'),pkg=JSON.parse(fs.readFileSync(path.join(source,'desktop/package.json'))); + if(dependencyKey(pkg)!==config.dependencyKey)throw Error('Dev dependencies changed; run install-dev again.'); + fs.symlinkSync(path.join(home,'dependencies/node_modules'),path.join(source,'desktop/node_modules')); + build(source); + const files=fingerprints(source); + const hostFiles=Object.fromEntries(Object.entries(files).filter(([n])=>/^desktop\/.*\.cjs$/.test(n)||/^desktop\/native\//.test(n)||n==='desktop/package.json'||n==='native/gamepad.swift')); + const uiFiles=Object.fromEntries(Object.entries(files).filter(([n])=>n.startsWith('desktop/')&&!n.endsWith('.cjs'))); + const manifest={ui:digest(JSON.stringify(uiFiles)),channel:'dev',revision,tree,version:pkg.version,builtAt:new Date().toISOString(),host:digest(JSON.stringify(hostFiles)),files}; + writeJSON(path.join(stage,'build.json'),manifest);fs.renameSync(stage,target); + }catch(error){fs.rmSync(stage,{recursive:true,force:true});throw error;} + } + const manifest=JSON.parse(fs.readFileSync(path.join(target,'build.json'))); + if(modifiedFiles(path.join(target,'easel'),manifest).length)throw Error('Staged dev build was modified.'); + const next=path.join(home,`current-${process.pid}`);fs.symlinkSync(target,next);fs.renameSync(next,current); + installed=manifest; + } + state.installed={revision:installed.revision,tree:installed.tree};state.state='current';writeJSON(stateFile,state);return state; + } catch(error) { + const state={...previous,channel:'dev',state:'error',online:false,attemptedAt:new Date().toISOString(),error:String(error.message).split('\n')[0].slice(0,180)}; + writeJSON(stateFile,state);throw error; + } finally {fs.rmSync(lock,{recursive:true,force:true});} +} +if(process.argv[1]===fileURLToPath(import.meta.url)){ + try{console.log(JSON.stringify(sync({fetch:!process.argv.includes('--no-fetch')})));}catch(error){console.error(error.message.split('\n')[0]);process.exitCode=1;} +} diff --git a/easel/test/dev-sync.test.mjs b/easel/test/dev-sync.test.mjs new file mode 100644 index 0000000000..08509358d1 --- /dev/null +++ b/easel/test/dev-sync.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import path from 'node:path';import os from 'node:os';import {execFileSync} from 'node:child_process';import {sync,dependencyKey} from '../dev/sync.mjs'; +function fixture(t){ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'aesel-dev-test-')),repo=path.join(dir,'repo'),home=path.join(dir,'home');fs.mkdirSync(path.join(repo,'easel/desktop'),{recursive:true});fs.mkdirSync(home); + const git=(...args)=>execFileSync('git',['-C',repo,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();git('init','-q');git('config','user.name','Fixture');git('config','user.email','fixture@example.invalid');git('config','commit.gpgsign','false'); + const pkg={version:'0.7.3',dependencies:{},devDependencies:{electron:'39.2.7'}};fs.writeFileSync(path.join(repo,'easel/desktop/package.json'),JSON.stringify(pkg));fs.writeFileSync(path.join(repo,'easel/desktop/main.cjs'),'// main'); + fs.writeFileSync(path.join(home,'config.json'),JSON.stringify({repo,dependencyKey:dependencyKey(pkg)})); + function commit(){git('add','.');git('commit','-qm','fixture');git('update-ref','refs/remotes/origin/main','HEAD');} + commit();t.after(()=>fs.rmSync(dir,{recursive:true,force:true}));return {home,repo,commit,git}; +} +test('sync installs immutable builds atomically and ignores unrelated commits',t=>{ + const f=fixture(t),args={home:f.home,fetch:false,build:()=>{}};const first=sync(args);const original=fs.realpathSync(path.join(f.home,'current')); + fs.writeFileSync(path.join(f.repo,'unrelated.txt'),'other project');f.commit();const unrelated=sync(args);assert.equal(unrelated.installed.tree,first.installed.tree);assert.equal(fs.realpathSync(path.join(f.home,'current')),original); + fs.writeFileSync(path.join(f.repo,'easel/desktop/main.cjs'),'// changed');f.commit();const next=sync(args);assert.notEqual(next.installed.tree,first.installed.tree);assert(fs.existsSync(original));assert.equal(fs.readFileSync(path.join(f.home,'current/easel/desktop/main.cjs'),'utf8'),'// changed'); +}); +test('local modifications are visible and never overwritten',t=>{const f=fixture(t),args={home:f.home,fetch:false,build:()=>{}};sync(args);const file=path.join(f.home,'current/easel/desktop/main.cjs');fs.writeFileSync(file,'local work');fs.writeFileSync(path.join(f.repo,'easel/desktop/main.cjs'),'remote work');f.commit();const status=sync(args);assert.equal(status.state,'modified');assert.equal(fs.readFileSync(file,'utf8'),'local work');}); +test('failed staging keeps current build and marks freshness unknown',t=>{const f=fixture(t),args={home:f.home,fetch:false,build:()=>{}};sync(args);const original=fs.realpathSync(path.join(f.home,'current'));fs.writeFileSync(path.join(f.repo,'easel/desktop/main.cjs'),'remote');f.commit();assert.throws(()=>sync({...args,build(){throw Error('build failed');}}),/build failed/);assert.equal(fs.realpathSync(path.join(f.home,'current')),original);assert.equal(JSON.parse(fs.readFileSync(path.join(f.home,'status.json'))).online,false);}); -- 2.51.2 From 1f93ee378d3117e0f06b6472e95f1aa529b5bd2d Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 17 Sep 2026 19:33:15 -0700 Subject: [PATCH 2/2] Guard development shell replacement before touching dependencies --- easel/dev/install.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/easel/dev/install.mjs b/easel/dev/install.mjs index 825870dacc..a1495fef66 100644 --- a/easel/dev/install.mjs +++ b/easel/dev/install.mjs @@ -14,6 +14,7 @@ const deps=option('--dependencies',path.join(source,'desktop/node_modules')); const app=path.join(os.homedir(),'Applications/Aesel Dev.app'); const pkg=JSON.parse(fs.readFileSync(path.join(source,'desktop/package.json'))); if(process.platform!=='darwin')throw Error('The fleet dev installer currently supports macOS.'); +if(fs.existsSync(app)&&execFileSync('/bin/ps',['-axo','command='],{encoding:'utf8'}).split('\n').some(line=>line.startsWith(path.join(app,'Contents/MacOS')+'/')))throw Error('Quit Aesel Dev before updating its Electron shell or dependencies.'); if(!fs.existsSync(base)||!fs.existsSync(deps))throw Error('Provide an installed Electron app and its extracted production node_modules.'); fs.mkdirSync(home,{recursive:true});fs.mkdirSync(path.join(home,'dependencies'),{recursive:true}); // Copy production Electron dependencies, including the matching node-pty binary. -- 2.51.2