From 19549f044db8d3da81e697d555162e7559b491e8 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 18 Sep 2026 11:10:59 -0700 Subject: [PATCH] Add named Aesel project apps and responsive waveform history --- easel/bin/piece-app.mjs | 116 +++++++++++++++++++++++ easel/desktop/README.md | 34 +++++++ easel/desktop/donkey.css | 4 +- easel/desktop/main.cjs | 69 ++++++++++---- easel/desktop/piece-app.cjs | 77 +++++++++++++++ easel/desktop/preview-waveform.js | 77 ++++++++------- easel/desktop/test/piece-app.test.cjs | 43 +++++++++ easel/src/slab-session.mjs | 1 + easel/test/preview-waveform.electron.cjs | 19 +++- easel/test/preview-waveform.test.mjs | 97 +++++++++++++++++++ 10 files changed, 479 insertions(+), 58 deletions(-) create mode 100644 easel/bin/piece-app.mjs create mode 100644 easel/desktop/piece-app.cjs create mode 100644 easel/desktop/test/piece-app.test.cjs create mode 100644 easel/test/preview-waveform.test.mjs diff --git a/easel/bin/piece-app.mjs b/easel/bin/piece-app.mjs new file mode 100644 index 0000000000..98316f2406 --- /dev/null +++ b/easel/bin/piece-app.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +// Local app-mode experiment. APFS clones keep the original Aesel untouched. +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import identity from '../desktop/piece-app.cjs'; + +const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const option = (name, fallback) => { const i = process.argv.indexOf(name); if (i < 0) return fallback; const value = process.argv[i + 1]; if (!value || value.startsWith('--')) throw Error(`${name} needs a value`); return value; }; +if (process.argv.includes('--help')) { + console.log('Usage: node easel/bin/piece-app.mjs --cwd PROJECT --name NAME --piece FILE --icon PNG [--instance window-UUID] [--base APP] [--open]\nCreates a local Mac app under ~/Applications/Aesel Pieces, preserving the project session. Re-running updates that same app identity.'); + process.exit(0); +} +if (process.platform !== 'darwin') throw Error('Piece apps currently require macOS.'); +const cwd = fs.realpathSync(option('--cwd', process.cwd())); +const name = option('--name', path.basename(cwd)).trim(); +const base = fs.realpathSync(option('--base', path.join(os.homedir(), 'Applications/aesel.app'))); +const stateRoot = path.resolve(option('--state-root', path.join(os.homedir(), 'Library/Application Support/Easel'))); +const previousFile = path.join(cwd, '.easel', 'app.json'); +const previous = identity.readPieceApp(previousFile); +const piece = fs.realpathSync(path.resolve(cwd, option('--piece', previous?.piece || `${name}.mjs`))); +const instance = option('--instance', previous?.instance || identity.findSessionInstance({workspace:cwd,piece,stateRoot, + fallback:/^window-[a-f0-9-]{36}$/.test(path.basename(cwd)) ? path.basename(cwd) : 'default'})); +if (path.relative(cwd, piece).startsWith('..' + path.sep) || path.relative(cwd, piece) === '..') throw Error('The piece must be inside its project.'); +const icon = fs.realpathSync(option('--icon', path.join(cwd, '.easel', 'icon.png'))); +const readPlist = file => JSON.parse(execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', file], { encoding: 'utf8' })); +const basePlist = readPlist(path.join(base, 'Contents/Info.plist')); +const baseExecutable = path.join(base, 'Contents/MacOS', basePlist.CFBundleExecutable); +const apps = path.join(os.homedir(), 'Applications/Aesel Pieces'); +const project = identity.projectIdentity({ workspace: cwd, name, instance, stateRoot, baseApp: base, + baseExecutable, appPath: path.join(apps, `${name}.app`), piece, previous }); +const target = project.appPath; +if (target === base) throw Error('A piece app cannot replace its Aesel runtime.'); +if (fs.existsSync(target) && !previous) throw Error('That app name is already installed; choose another name.'); +const isRunning = () => execFileSync('/bin/ps', ['-axo', 'command='], { encoding: 'utf8' }).split('\n').some(line => line.trimStart().startsWith(path.join(target, 'Contents/MacOS') + '/')); +if (isRunning()) throw Error('Quit this piece app before updating its bundle.'); +fs.mkdirSync(path.dirname(target), { recursive: true }); +const stage = target + `.stage-${process.pid}.app`; +if (fs.existsSync(stage)) throw Error('Staging path already exists.'); +const run = (cmd, args, options = {}) => execFileSync(cmd, args, { stdio: 'pipe', timeout: 120000, ...options }); +try { + run('/bin/cp', ['-cR', base, stage]); + const resources = path.join(stage, 'Contents/Resources'); + const appCode = path.join(resources, 'app'); + fs.rmSync(appCode, { recursive: true, force: true }); + const asar = createRequire(import.meta.url)(path.join(source, 'desktop/node_modules/@electron/asar')); + asar.extractAll(path.join(base, 'Contents/Resources/app.asar'), appCode); + const spawnHelper = path.join(appCode, 'node_modules/node-pty/build/Release/spawn-helper'); + if (fs.existsSync(spawnHelper)) fs.chmodSync(spawnHelper, 0o755); + fs.rmSync(path.join(resources, 'app.asar')); + // Freeze just the tested host changes into this prototype; retain the installed + // runtime/dependencies, and keep its native helpers in app.asar.unpacked. + for (const name of ['main.cjs', 'piece-app.cjs', 'net-clock.js', 'preview-waveform.js', 'donkey.css']) { + fs.copyFileSync(path.join(source, 'desktop', name), path.join(appCode, name)); + } + const ui = path.join(resources, 'easel/desktop-ui'); + if (fs.existsSync(ui)) for (const name of ['preview-waveform.js', 'donkey.css']) fs.copyFileSync(path.join(source, 'desktop', name), path.join(ui, name)); + const slab = path.join(resources, 'easel/src/slab-session.mjs'); + let slabText = fs.readFileSync(slab, 'utf8'); + if (!slabText.includes('host_bundle_id:')) slabText = slabText.replace("host_app:'computer.aesthetic.easel',", "host_app:'computer.aesthetic.easel',\n host_bundle_id:process.env.EASEL_HOST_BUNDLE_ID || 'computer.aesthetic.easel',"); + fs.writeFileSync(slab, slabText); + identity.atomicJSON(path.join(resources, 'piece-app.json'), project); + + // Use the actual piece pixels as its app icon; no generated artwork or model call. + const iconset = path.join(stage, 'piece.iconset'); fs.mkdirSync(iconset); + for (const size of [16, 32, 128, 256, 512]) for (const scale of [1, 2]) { + const px = String(size * scale); + run('/usr/bin/sips', ['-s', 'format', 'png', '-z', px, px, icon, '--out', path.join(iconset, `icon_${size}x${size}${scale === 2 ? '@2x' : ''}.png`)]); + } + fs.copyFileSync(path.join(iconset, 'icon_512x512@2x.png'), path.join(resources, 'piece-app.png')); + run('/usr/bin/iconutil', ['-c', 'icns', iconset, '-o', path.join(resources, 'piece-app.icns')]); + fs.rmSync(iconset, { recursive: true }); + // Electron resolves helpers from CFBundleName before running any app code. + const frameworks = path.join(stage, 'Contents/Frameworks'); + for (const suffix of [' Helper', ' Helper (GPU)', ' Helper (Renderer)', ' Helper (Plugin)']) { + const oldName = basePlist.CFBundleName + suffix, newName = name + suffix; + const helper = path.join(frameworks, oldName + '.app'); + if (!fs.existsSync(helper)) continue; + const helperInfo = path.join(helper, 'Contents/Info.plist'), helperPlist = readPlist(helperInfo); + fs.renameSync(path.join(helper, 'Contents/MacOS', helperPlist.CFBundleExecutable), path.join(helper, 'Contents/MacOS', newName)); + Object.assign(helperPlist, { CFBundleExecutable:newName, CFBundleName:newName, CFBundleDisplayName:newName, + CFBundleIdentifier:project.bundleId + '.helper' + (suffix.match(/\((\w+)\)/)?.[1] ? '.' + suffix.match(/\((\w+)\)/)[1].toLowerCase() : '') }); + fs.writeFileSync(helperInfo, JSON.stringify(helperPlist)); run('/usr/bin/plutil', ['-convert', 'xml1', helperInfo]); + fs.renameSync(helper, path.join(frameworks, newName + '.app')); + } + fs.renameSync(path.join(stage, 'Contents/MacOS', basePlist.CFBundleExecutable), path.join(stage, 'Contents/MacOS', name)); + const plist = { ...basePlist, CFBundleIdentifier: project.bundleId, CFBundleExecutable:name, CFBundleName: name, CFBundleDisplayName: name, CFBundleIconFile: 'piece-app.icns' }; + // The project must not take over Aesel's URL or document handlers, updater, + // or the integrity hash of the original app.asar. + for (const key of ['ElectronAsarIntegrity', 'CFBundleURLTypes', 'CFBundleDocumentTypes']) delete plist[key]; + const info = path.join(stage, 'Contents/Info.plist'); + fs.writeFileSync(info, JSON.stringify(plist)); run('/usr/bin/plutil', ['-convert', 'xml1', info]); + fs.rmSync(path.join(resources, 'app-update.yml'), { force: true }); + run('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', '--preserve-metadata=entitlements,flags', stage]); + run('/usr/bin/codesign', ['--verify', '--deep', '--strict', stage]); + // Exercise the renamed helpers and the extracted PTY launcher before install. + // This starts only a bounded local version probe, never an agent or prompt. + const probe = `const pty=require(${JSON.stringify(path.join(appCode, 'node_modules/node-pty'))});const t=pty.spawn(process.execPath,['-p','process.versions.electron'],{cwd:${JSON.stringify(cwd)},env:{...process.env,ELECTRON_RUN_AS_NODE:'1',NODE_OPTIONS:''}});let out='';t.onData(s=>out+=s);t.onExit(({exitCode})=>process.exit(exitCode===0&&out.includes(process.versions.electron)?0:1));setTimeout(()=>process.exit(1),8000).unref();`; + run(path.join(stage, 'Contents/MacOS', name), ['-e', probe], { env:{...process.env,ELECTRON_RUN_AS_NODE:'1',NODE_OPTIONS:''},timeout:15000 }); + if (isRunning()) throw Error('The piece app opened during installation; close it before retrying.'); + if (fs.existsSync(target)) { + const backup = path.join(stateRoot, 'piece-apps', project.id, `previous-${Date.now()}.app`); + fs.mkdirSync(path.dirname(backup), { recursive: true }); fs.renameSync(target, backup); + } + fs.renameSync(stage, target); + identity.registerProject(project); + identity.atomicJSON(previousFile, project); + console.log(JSON.stringify({ name, app: target, bundleId: project.bundleId, projectId: project.id, session: identity.sessionPath(project) })); + if (process.argv.includes('--open')) run('/usr/bin/open', ['-a', target]); +} catch (error) { + fs.rmSync(stage, { recursive: true, force: true }); + throw error; +} diff --git a/easel/desktop/README.md b/easel/desktop/README.md index 5696b25159..861ce24a85 100644 --- a/easel/desktop/README.md +++ b/easel/desktop/README.md @@ -71,3 +71,37 @@ setting recolor the terminal live. Media previews support Picture PNG, Sound WAV with waveform/playback, Paper source/PDF, and Game Boy ROMs through AC's bundled WasmBoy core. Game Boy building uses AC's GBDK stack; the compiler is installed separately. See `../media/gameboy`. + +### Project apps (local macOS prototype) + +App mode gives a project its own Dock name, icon and stable bundle ID. The +Projects menu and Dock menu share a registry of installed projects, mark running +apps, and open or focus the selected app. The existing Aesel installation remains +available. A single-icon Studio with multiple windows is the other intended +presentation; this prototype does not implement that window-host refactor. + +```sh +node easel/bin/piece-app.mjs --cwd /path/to/project --name sefo \ + --piece sefo.mjs --icon /path/to/piece-frame.png --open +``` + +The installer uses the installed `~/Applications/aesel.app` as its base (override +with `--base`), creates an APFS clone in `~/Applications/Aesel Pieces/`, renames +and signs its native helpers, and verifies a local PTY launch before installation. +It requires the repo's desktop development dependencies for ASAR extraction. +These are locally signed prototypes, not notarized apps for distribution. The +runtime is frozen in each clone; binary auto-update is disabled so an Aesel +release cannot replace the project's identity. Existing apps must be closed +before rerunning the installer; replaced bundles are retained beside their +registry entry for rollback. + +The project stores its identity in `.easel/app.json`; the shared registry is +`~/Library/Application Support/Easel/piece-apps//`. Renaming with the installer +preserves the bundle ID and installed path, so Dock shortcuts stay valid. The +current piece selects its existing saved session, including the case where a +project originally opened in a separate window was later reopened as the default +Aesel window. `--instance` explicitly selects a session identity when needed. +Reopening the app resumes its conversation and draft; opening it twice focuses +the existing process. Agent activity still appears in the prox ledger. The +registry currently provides discovery, liveness and launch/focus—not automatic +crash recovery or a separate supervisor daemon. diff --git a/easel/desktop/donkey.css b/easel/desktop/donkey.css index 4456e8283c..cbd2ab312d 100644 --- a/easel/desktop/donkey.css +++ b/easel/desktop/donkey.css @@ -311,8 +311,8 @@ body:not(.preview-fullscreen) #artifact-shell[data-resized]{width:var(--resized- #activity-caption::after{width:2px;height:2px;left:-15px;top:72%} #activity-caption[hidden]{display:none} -#preview-waveform{position:fixed;left:auto;right:var(--preview-audio-inset,80px);top:0;bottom:0;width:16px;height:100%;z-index:4;pointer-events:none;opacity:0;transition:opacity .35s;overflow:hidden} -#preview-waveform.sounding{opacity:.65} +#preview-waveform{position:fixed;left:auto;right:var(--preview-audio-inset,80px);top:0;bottom:0;width:16px;height:100%;z-index:4;pointer-events:none;opacity:0;transition:opacity .07s;overflow:hidden} +#preview-waveform.sounding{opacity:.65;transition:none} #preview-waveform path{fill:none;stroke:var(--aesel-accent,#ff78b2);stroke-width:1;stroke-linejoin:round;stroke-linecap:round} body.preview-fullscreen #preview-waveform{display:none} @media(prefers-reduced-motion:reduce){#preview-waveform{transition:none}} diff --git a/easel/desktop/main.cjs b/easel/desktop/main.cjs index 02bd967a2b..94cbeaf44a 100644 --- a/easel/desktop/main.cjs +++ b/easel/desktop/main.cjs @@ -11,10 +11,15 @@ const NetClock = require('./net-clock.js'); const {createUpdater} = require('./updater.cjs'); const {startFrameCapture} = require('./frame-capture.cjs'); const { tmpdir, homedir } = require('node:os'); +const {readPieceApp, registerRunning, listProjects} = require('./piece-app.cjs'); +const pieceApp = readPieceApp(join(process.resourcesPath, 'piece-app.json')); const devHome=process.env.AESEL_DEV_HOME||''; -app.setName(devHome?'Aesel Dev':'aesel'); -app.setPath('userData', join(app.getPath('appData'), devHome?'Aesel Dev':'Easel')); +app.setName(pieceApp?.name || (devHome?'Aesel Dev':'aesel')); +const stateRoot = pieceApp?.stateRoot || join(app.getPath('appData'), devHome?'Aesel Dev':'Easel'); +// Each named app gets its own OS singleton; saved project sessions remain shared +// with Studio, so switching presentation never forks the conversation. +app.setPath('userData', pieceApp ? join(stateRoot, 'app-profiles', pieceApp.id) : stateRoot); let window, terminal, timer, quitting = false, windowPainted = false; let lastVisibleState = null, keepPreviewOnStart = false; let terminalSize = {cols:100,rows:32}; @@ -41,19 +46,21 @@ let root = process.env.AESEL_DEV_ROOT || (app.isPackaged ? join(process.resource 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); -const option = name => { const at = supplied.indexOf(name); return at < 0 ? '' : supplied[at + 1] || ''; }; +const option = name => { const at = supplied.indexOf(name); return at < 0 ? (name === '--piece' ? pieceApp?.piece || '' : '') : supplied[at + 1] || ''; }; const launchFile = join(app.getPath('userData'), 'last-workspace.json'); let lastWorkspace = ''; try { const saved = JSON.parse(readFileSync(launchFile,'utf8')); if (typeof saved.cwd === 'string' && existsSync(saved.cwd)) lastWorkspace = saved.cwd; } catch {} -const workspace = resolve(option('--cwd') || lastWorkspace || join(app.getPath('userData'), 'projects', 'first-piece')); -const instance = option('--instance') || 'default'; +const workspace = resolve(pieceApp?.workspace || option('--cwd') || lastWorkspace || join(app.getPath('userData'), 'projects', 'first-piece')); +const instance = pieceApp?.instance || option('--instance') || 'default'; 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(); +if(independentWindow){const sessionData=join(stateRoot,'window-sessions',instance);mkdirSync(sessionData,{recursive:true,mode:0o700});app.setPath('sessionData',sessionData);} +const primaryInstance = pieceApp ? app.requestSingleInstanceLock() : independentWindow || app.requestSingleInstanceLock(); +if (!primaryInstance) { app.quit(); return; } +const releaseProject = pieceApp ? registerRunning(pieceApp) : () => {}; +process.once('exit', releaseProject); mkdirSync(app.getPath('userData'),{recursive:true,mode:0o700}); writeFileSync(launchFile,JSON.stringify({cwd:workspace}),{mode:0o600}); -const addressDir = join(app.getPath('userData'), 'instance-addresses'); +const addressDir = join(stateRoot, 'instance-addresses'); mkdirSync(addressDir,{recursive:true,mode:0o700}); function claimAddress() { for (let index=0; index<26; index++) { @@ -90,15 +97,15 @@ app.on('activate', () => { window.focus(); }); mkdirSync(workspace, { recursive: true }); -const sessionDir = join(app.getPath('userData'), 'sessions'); +const sessionDir = join(stateRoot, 'sessions'); mkdirSync(sessionDir, {recursive:true,mode:0o700}); const sessionFile = join(sessionDir, createHash('sha256').update(`${workspace}\0${instance}`).digest('hex') + '.json'); const continuationFile = sessionFile + '.continue'; -let continueSession = false; +let continueSession = !!pieceApp && existsSync(sessionFile); if (primaryInstance) { try { const marker = JSON.parse(readFileSync(continuationFile,'utf8')); - continueSession = marker.cwd === workspace && Date.now()-marker.at >= 0 && Date.now()-marker.at < 120000; + continueSession ||= marker.cwd === workspace && Date.now()-marker.at >= 0 && Date.now()-marker.at < 120000; rmSync(continuationFile,{force:true}); } catch {} } @@ -127,7 +134,7 @@ function requestRestart(action = 'restart') { writeFileSync(intentFile, JSON.stringify({action}), {mode:0o600}); terminal.kill('SIGUSR2'); } -const canUpdateBinary = !devHome && app.isPackaged && !process.mas && existsSync(join(process.resourcesPath,'app-update.yml')); +const canUpdateBinary = !pieceApp && !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 applicationMenuTemplate=null; @@ -138,7 +145,8 @@ const buildStatus=require('./build-status.cjs').createBuildStatus({app,devHome,r pendingDevAction=compatibility.sameHost?'restart':'update';applyPendingDev(); }}); 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());} + if(pieceApp){void shell.openPath(pieceApp.baseApp);send('desktop-notice','Manage runtime updates in Aesel. This project app keeps its own identity.');} + else 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();}); @@ -160,13 +168,14 @@ function start() { 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; + env.EASEL_HOST_BUNDLE_ID = pieceApp?.bundleId || 'computer.aesthetic.easel'; delete env.NO_COLOR; env.FORCE_COLOR = '3'; env.EASEL_GROUND = 'paint'; try { // Native extraction can discard mode bits; repair only our own launcher. if(process.platform!=='win32'&&!process.mas){ - const helper=join(devHome?join(root,'desktop'):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&&app.getAppPath().endsWith('.asar')?join(process.resourcesPath,'app.asar.unpacked'):__dirname,'node_modules/node-pty/build/Release/spawn-helper'); if(existsSync(helper))chmodSync(helper,0o755); } terminal = process.mas @@ -234,17 +243,17 @@ function start() { } function openNewWindow(){ const id=`window-${randomUUID()}`; - const cwd=join(app.getPath('userData'),'projects',id); + const cwd=join(stateRoot,'projects',id); mkdirSync(cwd,{recursive:true,mode:0o700}); const env={...process.env};delete env.ELECTRON_RUN_AS_NODE;delete env.NODE_OPTIONS; const args=[...(app.isPackaged?[]:[app.getAppPath()]),'--instance',id,'--cwd',cwd,'--window-requested-at',String(Date.now())]; - const child=spawn(process.execPath,args,{detached:true,stdio:'ignore',env}); + const child=spawn(pieceApp?.baseExecutable || process.execPath,args,{detached:true,stdio:'ignore',env}); child.on('error',error=>send('desktop-notice',`Could not open a window: ${error.message}`));child.unref(); } app.whenReady().then(() => { if (!primaryInstance) return; applicationMenuTemplate=[ - { label: devHome?'Aesel Dev':'Aesel', submenu: [ + { label: pieceApp?.name || (devHome?'Aesel Dev':'Aesel'), submenu: [ {id:'aesel-build-status',label:devHome?'Dev · checking…':'Release · checking…',enabled:false}, {role:'about'}, {type:'separator'}, {role:'close',accelerator:'CmdOrCtrl+W'}, {label:'Check for Updates…', click:checkBuildUpdates}, @@ -255,6 +264,7 @@ app.whenReady().then(() => { {type:'separator'}, {role:'quit'}, ] }, {label:'File',submenu:[{label:'New Window',accelerator:'CmdOrCtrl+N',click:openNewWindow}]}, + {id:'piece-apps',label:'Projects',submenu:[]}, { 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'));}}, @@ -269,8 +279,29 @@ app.whenReady().then(() => { { role: 'windowMenu' }, ]; Menu.setApplicationMenu(Menu.buildFromTemplate(applicationMenuTemplate)); - window = new BrowserWindow({ show:false, width: 760, height: 540, title: devHome?'Aesel Dev':'aesel', backgroundColor: currentTheme.background, + let projectMenuState = ''; + const refreshProjects = () => { + const projects = listProjects(stateRoot); + const key = JSON.stringify(projects.map(p => [p.id,p.name,p.pid,p.installed,p.appPath])); + if (key === projectMenuState) return; + projectMenuState = key; + const items = projects.map(p => ({label:p.name,type:'checkbox',checked:!!p.pid,enabled:p.installed,click:()=>shell.openPath(p.appPath)})); + if (pieceApp) items.push({type:'separator'},{label:'Open Aesel',click:()=>shell.openPath(pieceApp.baseApp)}); + if (!items.length) items.push({label:'No project apps yet',enabled:false}); + applicationMenuTemplate.find(item=>item.id==='piece-apps').submenu=items; + Menu.setApplicationMenu(Menu.buildFromTemplate(applicationMenuTemplate)); + if (app.dock) app.dock.setMenu(Menu.buildFromTemplate(items)); + }; + refreshProjects(); + const projectMenuTimer = setInterval(refreshProjects, 3000); projectMenuTimer.unref(); + app.once('will-quit', () => { clearInterval(projectMenuTimer); releaseProject(); }); + if (pieceApp && app.dock) { + const icon = nativeImage.createFromPath(join(process.resourcesPath, 'piece-app.png')); + if (!icon.isEmpty()) app.dock.setIcon(icon); + } + window = new BrowserWindow({ show:false, width: 760, height: 540, title: pieceApp?.name || (devHome?'Aesel Dev':'aesel'), backgroundColor: currentTheme.background, webPreferences: { additionalArguments:[`--aesel-initial-theme=${JSON.stringify(currentTheme)}`], preload: join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true, webviewTag: true, plugins:true } }); + if (pieceApp) window.on('page-title-updated', event => event.preventDefault()); window.once('ready-to-show',()=>{windowPainted=true;window.show();console.log(JSON.stringify({event:'window-presented',ms:Date.now()-startedAt,requestedMs:option('--window-requested-at')?Date.now()-Number(option('--window-requested-at')):undefined}));}); const creditLabel = require('./credit-label.cjs').startCreditLabel({app, window, root}); require('./credit-checkout.cjs').startCreditCheckout({app,window,root,ipcMain,shell,refresh:creditLabel.refresh}); diff --git a/easel/desktop/piece-app.cjs b/easel/desktop/piece-app.cjs new file mode 100644 index 0000000000..5d7271e2d4 --- /dev/null +++ b/easel/desktop/piece-app.cjs @@ -0,0 +1,77 @@ +// A persistent project identity shared by app-mode and the Aesel studio. +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash, randomUUID } = require('node:crypto'); + +function validatePieceApp(value) { + if (value?.format !== 1 || !/^[a-f0-9-]{36}$/.test(value.id || '') || + typeof value.name !== 'string' || !value.name.trim() || value.name.length > 64 || /[\x00-\x1f/\\:]/.test(value.name) || + !['workspace', 'stateRoot', 'appPath', 'baseApp', 'baseExecutable', 'piece'].every(key => typeof value[key] === 'string' && path.isAbsolute(value[key])) || + !/^(default|window-[a-f0-9-]{36})$/.test(value.instance || '') || + value.bundleId !== `computer.aesthetic.aesel.piece.${value.id.replaceAll('-', '')}`) { + throw new Error('Invalid Aesel piece app identity'); + } + return value; +} + +function readPieceApp(file) { + try { return validatePieceApp(JSON.parse(fs.readFileSync(file, 'utf8'))); } + catch (error) { if (error.code === 'ENOENT') return null; throw error; } +} + +function projectIdentity({ workspace, name, instance, stateRoot, appPath, baseApp, baseExecutable, piece, previous }) { + const id = previous ? validatePieceApp(previous).id : randomUUID(); + return validatePieceApp({ format: 1, id, name, workspace, instance, stateRoot, + appPath: previous?.appPath || appPath, baseApp, baseExecutable, piece, + bundleId: `computer.aesthetic.aesel.piece.${id.replaceAll('-', '')}` }); +} + +const sessionPath = project => path.join(project.stateRoot, 'sessions', + createHash('sha256').update(`${project.workspace}\0${project.instance}`).digest('hex') + '.json'); +function findSessionInstance({workspace, piece, stateRoot, fallback = 'default'}) { + const candidates = new Set([fallback, 'default']); + if (/^window-[a-f0-9-]{36}$/.test(path.basename(workspace))) candidates.add(path.basename(workspace)); + return [...candidates].flatMap(instance => { + try { + const saved = JSON.parse(fs.readFileSync(sessionPath({workspace, stateRoot, instance}), 'utf8')); + if (saved.cwd !== workspace || saved.live?.file !== piece || !Number.isFinite(Date.parse(saved.savedAt))) return []; + return [{instance, at:Date.parse(saved.savedAt)}]; + } catch { return []; } + }).sort((a,b)=>b.at-a.at)[0]?.instance || fallback; +} +const registryDirectory = stateRoot => path.join(stateRoot, 'piece-apps'); +function atomicJSON(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + const temporary = file + '.' + randomUUID() + '.tmp'; + try { fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); fs.renameSync(temporary, file); } + finally { fs.rmSync(temporary, { force: true }); } +} +function registerProject(project) { + validatePieceApp(project); + atomicJSON(path.join(registryDirectory(project.stateRoot), project.id, 'project.json'), project); +} +function registerRunning(project, { pid = process.pid, alive = n => process.kill(n, 0) } = {}) { + const file = path.join(registryDirectory(project.stateRoot), project.id, 'running.json'); + try { + const old = JSON.parse(fs.readFileSync(file, 'utf8')); + if (old.pid !== pid) { try { alive(old.pid); throw new Error('This piece app is already running'); } catch (error) { if (error.code !== 'ESRCH') throw error; } } + } catch (error) { if (error.code !== 'ENOENT') throw error; } + atomicJSON(file, { pid, bundleId: project.bundleId, startedAt: new Date().toISOString() }); + return () => { try { if (JSON.parse(fs.readFileSync(file, 'utf8')).pid === pid) fs.rmSync(file); } catch {} }; +} +function listProjects(stateRoot, { alive = pid => process.kill(pid, 0) } = {}) { + const root = registryDirectory(stateRoot); + let names; try { names = fs.readdirSync(root); } catch { return []; } + return names.flatMap(id => { + try { + if (!/^[a-f0-9-]{36}$/.test(id)) return []; + const project = readPieceApp(path.join(root, id, 'project.json')); + if (!project || project.id !== id) return []; + let pid = null; + try { const running = JSON.parse(fs.readFileSync(path.join(root, id, 'running.json'), 'utf8')); if (Number.isInteger(running.pid) && running.pid > 0) { alive(running.pid); pid = running.pid; } } catch {} + return [{ ...project, pid, installed: fs.existsSync(project.appPath) }]; + } catch { return []; } + }).sort((a, b) => a.name.localeCompare(b.name)); +} + +module.exports = { validatePieceApp, readPieceApp, projectIdentity, sessionPath, findSessionInstance, atomicJSON, registerProject, registerRunning, listProjects }; diff --git a/easel/desktop/preview-waveform.js b/easel/desktop/preview-waveform.js index 950e026cbf..231e1209ee 100644 --- a/easel/desktop/preview-waveform.js +++ b/easel/desktop/preview-waveform.js @@ -24,35 +24,40 @@ window.installPreviewWaveform = (preview) => { window.addEventListener("resize", align); align(); const motion = matchMedia("(prefers-reduced-motion: reduce)"); + const historyMs = 4000; // Two 4/4 bars at 120 BPM, oldest at the top. + let history = []; let ready = false, timer = 0, generation = 0, - frame = 0, - previousFrame = 0, - target = [], - displayed = [], - lastSound = 0; + previousRead = 0, + peakEnvelope = 0, + frame = 0; const clear = () => { line.classList.remove("sounding"); - lastSound = 0; + path.setAttribute("d", "M0 16H511"); + previousRead = 0; + peakEnvelope = 0; + history = []; cancelAnimationFrame(frame); frame = 0; - previousFrame = 0; - target = []; - displayed = []; }; - function animate(now) { + function draw(now) { frame = 0; - if (!ready || document.hidden || !line.classList.contains("sounding")) return; - const blend = 1 - Math.exp(-Math.min(64, now - (previousFrame || now - 16)) / 35); - previousFrame = now; - displayed = target.map((v, i) => (displayed[i] ?? v) + (v - (displayed[i] ?? v)) * blend); - path.setAttribute("d", displayed.map((v, i) => `${i ? "L" : "M"}${i} ${(16 - Math.tanh(v * 4) * 13).toFixed(2)}`).join("")); - frame = requestAnimationFrame(animate); + if (!ready || document.hidden || preview.hidden || preview.isAudioMuted() || document.body.classList.contains("preview-fullscreen")) { clear(); return; } + history = history.filter(point => now - point.at < historyMs); + if (!history.some(point => point.active)) { clear(); return; } + line.classList.add("sounding"); + // Envelope slices retain transients instead of aliasing an entire audio + // cycle down to a pixel. Their timestamps set the upward scroll speed. + const position = point => (511 * (1 - (now - point.at) / historyMs)).toFixed(2); + const edge = (point, side) => `${position(point)} ${(16 + point[side] * 13).toFixed(2)}`; + path.setAttribute("d", `M${position(history[0])} 16${history.map(point => `L${edge(point, "low")}`).join("")}L511 16${history.toReversed().map(point => `L${edge(point, "high")}`).join("")}Z`); + frame = requestAnimationFrame(draw); } async function poll() { const current = generation; - let active = false; + const started = performance.now(); + let interval = 200; try { if ( !ready || @@ -64,6 +69,9 @@ window.installPreviewWaveform = (preview) => { clear(); return; } + // Keep listening during silence: the bird's whole first note is 75 ms. + // Only one guest read is in flight, and hidden/muted previews back off. + interval = motion.matches ? 33 : 16; const samples = await preview.executeJavaScript( "window.AC?.readOutputWaveform?.(512) || []", ); @@ -73,30 +81,33 @@ window.installPreviewWaveform = (preview) => { .slice(0, 512) .map((v) => (Number.isFinite(v) ? Math.max(-1, Math.min(1, v)) : 0)) : []; - active = values.some((v) => Math.abs(v) > 0.0005); - if (active) lastSound = performance.now(); - line.classList.toggle( - "sounding", - !!lastSound && performance.now() - lastSound < 600, - ); - if (!line.classList.contains("sounding")) return; + const mean = values.reduce((sum, v) => sum + v, 0) / (values.length || 1); + const centered = values.map(v => v - mean); + const peak = centered.reduce((max, v) => Math.max(max, Math.abs(v)), 0); + const active = peak > 0.0005; + const now = performance.now(); + // Normalize the display only. Follow peaks immediately, release gain + // gently, and cap amplification so near-silence doesn't fill the strip. + peakEnvelope = Math.max(peak, peakEnvelope * Math.exp(-(now - previousRead) / 180)); + previousRead = now; + const gain = Math.min(32, 0.85 / (peakEnvelope || 1)); if (motion.matches) { - cancelAnimationFrame(frame); frame = 0; + cancelAnimationFrame(frame); frame = 0; history = []; + line.classList.toggle("sounding", active); path.setAttribute("d", "M0 16H511"); } else { - // Resample older guests too, so a loading piece never changes geometry. - target = Array.from({length:512}, (_, i) => { - const at = i * Math.max(0, values.length - 1) / 511; - const lo = Math.floor(at), fraction = at - lo; - return (values[lo] || 0) * (1 - fraction) + (values[Math.min(lo + 1, values.length - 1)] || 0) * fraction; - }); - if (!frame) frame = requestAnimationFrame(animate); + // Normalization changes only each new slice, never the recorded past. + history.push({at: now, active, + low: active ? Math.min(...centered) * gain : 0, + high: active ? Math.max(...centered) * gain : 0}); + // No second animation loop or queue of guest requests. + if (!frame) draw(now); } } catch { clear(); } finally { if (current === generation && ready) - timer = setTimeout(poll, active && !motion.matches ? 33 : 200); + timer = setTimeout(poll, Math.max(4, interval - (performance.now() - started))); } } const stop = () => { diff --git a/easel/desktop/test/piece-app.test.cjs b/easel/desktop/test/piece-app.test.cjs new file mode 100644 index 0000000000..fa4c3b2c88 --- /dev/null +++ b/easel/desktop/test/piece-app.test.cjs @@ -0,0 +1,43 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const {createHash} = require('node:crypto'); +const {projectIdentity,validatePieceApp,sessionPath,findSessionInstance,registerProject,registerRunning,listProjects} = require('../piece-app.cjs'); + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'piece-app-test-')); + t.after(() => fs.rmSync(root, {recursive:true,force:true})); + const p = projectIdentity({workspace:root,name:'goti',instance:'window-6c491a98-7517-4d00-9c8b-6694965014dd',stateRoot:root,appPath:path.join(root,'goti.app'),baseApp:path.join(root,'Aesel.app'),baseExecutable:path.join(root,'Aesel.app/Contents/MacOS/aesel'),piece:path.join(root,'goti.mjs')}); + fs.mkdirSync(p.appPath); return p; +} +test('renaming a project preserves the app identity, pinned app path and existing session', t => { + const p = fixture(t), next = projectIdentity({...p,previous:p,name:'bird',appPath:'/ignored/bird.app'}); + assert.equal(next.id,p.id); assert.equal(next.bundleId,p.bundleId); assert.equal(next.appPath,p.appPath); + const expected = path.join(p.stateRoot,'sessions',createHash('sha256').update(`${p.workspace}\0${p.instance}`).digest('hex')+'.json'); + assert.equal(sessionPath(p),expected); assert.equal(sessionPath(next),expected); + assert.notEqual(projectIdentity({...p,previous:undefined}).bundleId,p.bundleId); +}); +test('registry distinguishes installed, running and stale apps, and cleanup is owner-scoped', t => { + const p=fixture(t); registerProject(p); + assert.equal(listProjects(p.stateRoot)[0].pid,null); + const release=registerRunning(p); assert.equal(listProjects(p.stateRoot)[0].pid,process.pid); + assert.throws(()=>registerRunning(p,{pid:process.pid+1,alive(){}}),/already running/); + release(); assert.equal(listProjects(p.stateRoot)[0].pid,null); + const stale=registerRunning(p,{pid:9999999}); + assert.equal(listProjects(p.stateRoot,{alive(){throw Object.assign(Error('gone'),{code:'ESRCH'});}})[0].pid,null); + const current=registerRunning(p,{alive(){throw Object.assign(Error('gone'),{code:'ESRCH'});}}); + stale(); assert.equal(listProjects(p.stateRoot)[0].pid,process.pid); current(); + fs.rmSync(p.appPath,{recursive:true}); assert.equal(listProjects(p.stateRoot)[0].installed,false); +}); +test('invalid names, paths and conflicting bundle identities are rejected', t => { + const p=fixture(t); + for(const values of [{name:'../bad'},{name:'a:b'},{name:''},{workspace:'relative'},{bundleId:'computer.aesthetic.easel'},{instance:'../default'}])assert.throws(()=>validatePieceApp({...p,...values}),/Invalid/); +}); +test('finds a project reopened as the default window without restoring an older piece', t => { + const p=fixture(t); fs.mkdirSync(path.join(p.stateRoot,'sessions')); + fs.writeFileSync(sessionPath(p),JSON.stringify({cwd:p.workspace,live:{file:'/old-piece.mjs'},savedAt:'2026-09-17T00:00:00Z'})); + fs.writeFileSync(sessionPath({...p,instance:'default'}),JSON.stringify({cwd:p.workspace,live:{file:p.piece},savedAt:'2026-09-18T00:00:00Z'})); + assert.equal(findSessionInstance({...p,fallback:p.instance}),'default'); +}); diff --git a/easel/src/slab-session.mjs b/easel/src/slab-session.mjs index 88c9be020d..b76035a2c2 100644 --- a/easel/src/slab-session.mjs +++ b/easel/src/slab-session.mjs @@ -61,6 +61,7 @@ export class SlabSession { agent_type: "easel", ...(process.env.EASEL_DESKTOP === '1' ? { host_app:'computer.aesthetic.easel', + host_bundle_id:process.env.EASEL_HOST_BUNDLE_ID || 'computer.aesthetic.easel', host_pid:Number(process.env.EASEL_HOST_PID)||process.ppid, host_window_id:Number(process.env.EASEL_HOST_WINDOW_ID)||0, }:{}), diff --git a/easel/test/preview-waveform.electron.cjs b/easel/test/preview-waveform.electron.cjs index e28661639a..b7e404ffd7 100644 --- a/easel/test/preview-waveform.electron.cjs +++ b/easel/test/preview-waveform.electron.cjs @@ -85,8 +85,19 @@ let win; const points = [...waveform.matchAll(/[ML][\d.]+ ([\d.]+)/g)].map( (m) => +m[1], ); - assert.equal(points.length,512,'512 samples along the full height'); + assert(points.length > 12, 'A scrolling envelope retains multiple audio slices'); assert(Math.max(...points) - Math.min(...points) > 5); + assert.equal(await js(`getComputedStyle(document.getElementById('preview-waveform')).transitionDuration`), '0s', 'Sound onset never fades in'); + // Four real 75 ms notes make the bottom-up history visible and inspectable. + guest.setAudioMuted(true); await delay(100); + await guest.executeJavaScript(`gain.gain.setValueAtTime(0,ctx.currentTime)`); + guest.setAudioMuted(false); await delay(220); + await guest.executeJavaScript(`(()=>{const t=ctx.currentTime+.05;for(let i=0;i<4;i++){gain.gain.setValueAtTime(.06,t+i);gain.gain.setValueAtTime(0,t+i+.075);}})()`); + await delay(3400); + fs.writeFileSync('/tmp/aesel-waveform-rhythm.png',(await win.webContents.capturePage()).toPNG()); + const bands = await js(`(()=>{const points=[...document.querySelector('#preview-waveform path').getAttribute('d').matchAll(/[ML]([\\d.]+) ([\\d.]+)/g)].map(m=>({at:+m[1],amp:Math.abs(+m[2]-16)}));return points.filter(p=>p.amp>2).map(p=>Math.floor(p.at/128));})()`); + assert(new Set(bands).size >= 3, 'Short notes remain separated across the scrolling history'); + await guest.executeJavaScript(`gain.gain.setValueAtTime(.12,ctx.currentTime)`); assert.equal( await js( `getComputedStyle(document.getElementById('preview-waveform')).pointerEvents`, @@ -140,13 +151,13 @@ let win; ); guest.setAudioMuted(false); await guest.executeJavaScript("gain.gain.value=0"); - await delay(900); + await delay(4200); assert.equal( await js( `document.getElementById('preview-waveform').classList.contains('sounding')`, ), false, - "Silence hides the line", + "Silence hides the line when the four-second history has scrolled out", ); await guest.executeJavaScript("gain.gain.value=.12"); await delay(300); @@ -177,7 +188,7 @@ let win; "0px", ); console.log( - "PASS: live audio, silence, mute, navigation, narrow thought-bubble row, shared bounce, reduced motion.", + "PASS: live audio, short-note history, immediate opacity, silence, mute, navigation, narrow thought-bubble row, shared bounce, reduced motion.", ); })() .catch((e) => { diff --git a/easel/test/preview-waveform.test.mjs b/easel/test/preview-waveform.test.mjs new file mode 100644 index 0000000000..1d823ba60b --- /dev/null +++ b/easel/test/preview-waveform.test.mjs @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +const source = readFileSync(new URL('../desktop/preview-waveform.js', import.meta.url), 'utf8'); +function harness() { + let now = 0, serial = 0, samples = [], reads = 0, pending = null; + const tasks = new Map(), elements = []; + const node = () => ({ + attributes: {}, events: {}, hidden: false, + classList: { values: new Set(), add(v) { this.values.add(v); }, remove(v) { this.values.delete(v); }, contains(v) { return this.values.has(v); }, toggle(v, on) { if (on) this.add(v); else this.remove(v); } }, + setAttribute(k, v) { this.attributes[k] = v; }, append() {}, + addEventListener(k, fn) { this.events[k] = fn; }, removeEventListener() {}, + }); + const document = { ...node(), body: node(), documentElement: { style: { setProperty() {} } }, + createElementNS() { const n = node(); elements.push(n); return n; }, getElementById() { return null; } }; + const preview = { ...node(), muted: false, isAudioMuted() { return this.muted; }, + async executeJavaScript() { reads++; return pending ? pending : samples; } }; + const schedule = (fn, ms) => { const id = ++serial; tasks.set(id, { at: now + ms, fn }); return id; }; + const motion = { matches: false }; + const window = node(); + vm.runInNewContext(source, { window, document, performance: { now: () => now }, + matchMedia: () => motion, ResizeObserver: class { observe() {} disconnect() {} }, + queueMicrotask() {}, setTimeout: schedule, clearTimeout: id => tasks.delete(id), + requestAnimationFrame: fn => schedule(() => fn(now), 16), cancelAnimationFrame: id => tasks.delete(id) }); + window.installPreviewWaveform(preview); + preview.events['dom-ready'](); + const flush = async () => { for (let i = 0; i < 5; i++) await Promise.resolve(); }; + return { preview, document, motion, line: elements[0], path: elements[1], tasks, + samples(value) { samples = value; }, pending(value) { pending = value; }, reads: () => reads, + async advance(ms) { + await flush(); const end = now + ms; + while (true) { + const next = [...tasks.entries()].sort((a, b) => a[1].at - b[1].at)[0]; + if (!next || next[1].at > end) break; + now = next[1].at; tasks.delete(next[0]); next[1].fn(); await flush(); + } + now = end; await flush(); + }, + }; +} +const wave = amplitude => Array.from({ length: 512 }, (_, i) => (i % 2 ? amplitude : -amplitude)); +const points = h => [...(h.path.attributes.d || '').matchAll(/[ML]([\d.]+) ([\d.]+)/g)].map(m => ({ y: +m[1], x: +m[2] })); +const sounding = h => h.line.classList.contains('sounding'); + +test('short sound appears at the bottom within a frame, travels up, then expires', async () => { + const h = harness(); await h.advance(505); + h.samples(wave(.04)); await h.advance(16); + assert(sounding(h)); + let peaks = points(h).filter(p => Math.abs(p.x - 16) > 2); + assert(peaks.length && peaks.every(p => p.y > 508), 'onset starts at bottom, not stretched over the window'); + h.samples([]); await h.advance(100); + peaks = points(h).filter(p => Math.abs(p.x - 16) > 2); + assert(peaks.every(p => p.y < 503), 'silent bottom flattens promptly'); + await h.advance(1900); + peaks = points(h).filter(p => Math.abs(p.x - 16) > 2); + assert(peaks.every(p => p.y > 250 && p.y < 260), 'sound crosses the middle after two seconds'); + await h.advance(2100); + assert.equal(sounding(h), false, 'no flatline hold after history has left the top'); +}); + +test('normalizes quiet and loud sounds without inflating noise or rewriting history', async () => { + const widths = []; + for (const level of [.03, .8]) { + const h = harness(); await h.advance(20); h.samples(wave(level)); await h.advance(32); + widths.push(Math.max(...points(h).map(p => p.x)) - Math.min(...points(h).map(p => p.x))); + const old = points(h).filter(p => p.x !== 16).map(p => p.x); + h.samples(wave(.001)); await h.advance(16); + for (const x of old) assert(points(h).some(p => p.x === x)); + } + assert(widths.every(w => w > 20 && w < 24)); + assert(Math.abs(widths[0] - widths[1]) < .1); + const h = harness(); h.samples(wave(.0001)); await h.advance(100); + assert.equal(sounding(h), false); + h.samples(wave(.001)); await h.advance(20); + assert(Math.max(...points(h).map(p => Math.abs(p.x - 16))) < 1, 'gain is capped near silence'); +}); + +test('mute, hidden windows, navigation and reduced motion clear scrolling history', async () => { + for (const stop of [h => { h.preview.muted = true; }, h => { h.document.hidden = true; }, h => h.preview.events['did-start-loading']()]) { + const h = harness(); h.samples(wave(.2)); await h.advance(32); assert(sounding(h)); + stop(h); await h.advance(32); assert.equal(sounding(h), false); + } + const h = harness(); h.motion.matches = true; h.samples(wave(.2)); await h.advance(40); + assert(sounding(h)); assert.equal(h.path.attributes.d, 'M0 16H511'); + h.samples([]); await h.advance(40); assert.equal(sounding(h), false); +}); + +test('slow guest reads do not overlap and old replies cannot repopulate a new page', async () => { + const h = harness(); await h.advance(20); + let resolve; h.pending(new Promise(r => { resolve = r; })); await h.advance(20); + const reads = h.reads(); await h.advance(500); assert.equal(h.reads(), reads); + h.preview.events['did-start-loading'](); h.pending(null); resolve(wave(.5)); await h.advance(20); + assert.equal(sounding(h), false); + h.preview.events['did-stop-loading'](); h.samples(wave(.5)); await h.advance(32); assert(sounding(h)); +}); -- 2.51.2