diff --git a/aesel/README.md b/aesel/README.md index 1b709b3877..08b835565a 100644 --- a/aesel/README.md +++ b/aesel/README.md @@ -314,6 +314,27 @@ are not cleared. Claude uses `--bare`; Codex uses `--no-daemon`. The direct Aesel target uses its real agent loop with only the inference endpoint replaced. For a narrow run: `python3 bin/bench-tui.py --columns 40 --runs 5 --compare`. +To compare actual hosted inference, run from `aesel/`: + +```sh +npm run bench:providers -- --live --task ping --runs 3 --out ../tmp/provider-speed/ping.json +npm run bench:providers -- --live --task sequence --runs 3 --out ../tmp/provider-speed/sequence.json +npm run bench:providers -- --live --task coding --runs 3 --out ../tmp/provider-speed/coding.json +``` + +`--live` spends the signed-in AC and Codex accounts' allowance. Each sample uses +a fresh temporary workspace and thread with a synthetic prompt; the report omits +reply text and credentials. Runs alternate provider order and record connection, +first text, completion, model, reasoning, usage, and validation. The coding task +checks an interval merger against seven cases, including input preservation. +Timeouts and incorrect answers fail instead of counting as fast responses. +`--targets ac` or `--targets codex` limits the comparison; `--ac-model` and +`--codex-model` override defaults. Different models, system prompts, tool +configuration, and cache histories make this a product comparison, not an +equal-quality model evaluation. With three samples, p95 is simply the maximum. +Engine events do not measure GUI paint latency. Native launch/control timing has +a separate [debug-app benchmark](../apple/aesel/README.md). + ## Aesel pro modules Three dependency-free modules under `src/` carry the pro (terminal harness) diff --git a/aesel/bin/bench-providers.mjs b/aesel/bin/bench-providers.mjs new file mode 100644 index 0000000000..e4e08a7525 --- /dev/null +++ b/aesel/bin/bench-providers.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// Explicitly opt-in paid inference. Only synthetic prompts; reports omit text and credentials. +import {mkdtemp, mkdir, writeFile, rm, readFile} from 'node:fs/promises'; +import {tmpdir,platform,arch,loadavg} from 'node:os'; +import {join} from 'node:path'; +import {createHash} from 'node:crypto'; +import {execFileSync} from 'node:child_process'; +import {parseArgs} from 'node:util'; +import {performance} from 'node:perf_hooks'; +import {AcServer} from '../src/ac-server.mjs'; +import {AppServer} from '../src/app-server.mjs'; +import {ACSession} from '../src/ac-session.mjs'; +import {tasks} from './speed-tasks.mjs'; + +const {values}=parseArgs({options:{live:{type:'boolean'},runs:{type:'string',default:'3'},out:{type:'string'},timeout:{type:'string',default:'60'},targets:{type:'string',default:'ac,codex'},task:{type:'string',default:'ping'},'ac-model':{type:'string',default:''},'codex-model':{type:'string',default:''},'ac-reasoning':{type:'string'}}}); +if(!values.live)throw Error('Pass --live to spend provider allowance on synthetic latency requests.'); +const runs=Number(values.runs),timeout=Number(values.timeout)*1000,targets=values.targets.split(','); +if(!Number.isInteger(runs)||runs<1||runs>10||!Number.isFinite(timeout)||timeout<1000||timeout>180000||targets.some(t=>!['ac','codex'].includes(t)))throw Error('Invalid benchmark options'); +if(values['ac-reasoning'] && !['none','minimal','low','medium','high'].includes(values['ac-reasoning']))throw Error('Invalid reasoning effort'); +if(!Object.hasOwn(tasks,values.task))throw Error('Task must be ping, sequence, or coding'); +const prompt='This is a response latency test. Do not use tools, read files, or change anything. '+tasks[values.task].prompt; +const instruction='This session benchmarks response latency with a synthetic prompt. Answer directly, without tools, file changes, planning, or publishing.'; +const account=new ACSession(); +const report={schema:1,task:values.task,measurement:'Real Aesel provider engines; fresh isolated working directory and thread per sample; existing account authentication; defaults unless overrides are recorded. Engine events, not screen pixels. Models and system prompts can differ.',startedAt:new Date().toISOString(),runs,samples:[]}; +report.environment={platform:platform(),arch:arch(),node:process.version,loadAverage:loadavg(),...(targets.includes('codex')?{codex:execFileSync('codex',['--version'],{encoding:'utf8',timeout:10000}).trim()}:{})}; +report.sourceSha256=Object.fromEntries(await Promise.all(['ac-server.mjs','app-server.mjs','provider-defaults.mjs','open-models.mjs'].map(async file=>[file,createHash('sha256').update(await readFile(new URL('../src/'+file,import.meta.url))).digest('hex')]))); +const percentile=(a,p)=>a.length?[...a].sort((a,b)=>a-b)[Math.max(0,Math.ceil(a.length*p)-1)]:null; +async function measure(target){ + const cwd=await mkdtemp(join(tmpdir(),'aesel-provider-speed-')); + const sample={target,status:'failed',requestedModel:values[target+'-model'],reasoning:target==='ac'?values['ac-reasoning']??'default':'configured default'}; + let engine,timer,first=null,text='',usage=null,started; + try{ + engine=target==='ac'?new AcServer({cwd,workspace:true,model:values['ac-model'],token:()=>account.token(),developerInstructions:instruction,jev:null,rounds:1,preview:false,...(values['ac-reasoning']?{reasoning:{effort:values['ac-reasoning']}}:{})}):new AppServer({cwd,model:values['codex-model'],developerInstructions:instruction}); + let finish; + const completed=new Promise(resolve=>{finish=resolve;}); + engine.on('request',r=>engine.reject(r.id,-32000,'Tools are disabled for this benchmark')); + engine.on('notification',({method,params={}})=>{ + if(method==='model/reported')sample.reportedModel=params.reported; + if(method==='item/agentMessage/delta'){if(first===null)first=performance.now();text+=params.delta??'';} + if(method==='turn/usage'||method==='usage/updated'||method==='thread/tokenUsage/updated')usage=params.usage??params.tokenUsage; + if(method==='turn/completed')finish(params.turn); + }); + engine.on('error',()=>finish({status:'failed',error:{message:'Engine error'}})); + engine.on('exit',()=>finish({status:'failed',error:{message:'Engine exited'}})); + const begin=performance.now(); + const expired=new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Sample timed out')),timeout);}); + await Promise.race([(async()=>{ + const connection=await engine.connect();sample.connectMs=performance.now()-begin; + sample.configuredModel=connection?.model??engine.model; + if(target==='codex'&&connection?.reasoningEffort)sample.reasoning=connection.reasoningEffort; + started=performance.now(); + const start=engine.startTurn(prompt); + await start; + const turn=await completed; + sample.totalMs=performance.now()-started; + sample.firstTextMs=first===null?null:first-started; + sample.outputCharacters=text.length;sample.matchesExpected=tasks[values.task].check(text); + if(turn?.status!=='completed')throw Error(turn?.error?.message||'Turn failed'); + sample.status=sample.matchesExpected?'ok':'unexpected-output'; + if(usage)sample.usage=usage; + })(),expired]); + }catch(error){sample.error=String(error.message).slice(0,300);} + finally{clearTimeout(timer);engine?.close();await rm(cwd,{recursive:true,force:true});} + return sample; +} +for(let run=0;run{ + const samples=report.samples.filter(s=>s.target===target),ok=samples.filter(s=>s.status==='ok'); + return [target,{passed:ok.length,attempted:samples.length,...Object.fromEntries(['connectMs','firstTextMs','totalMs'].map(metric=>{const a=ok.map(s=>s[metric]).filter(Number.isFinite);return [metric,{p50:percentile(a,.5),p95:percentile(a,.95)}];}))}]; +})); +if(values.out){await mkdir(join(values.out,'..'),{recursive:true});await writeFile(values.out,JSON.stringify(report,null,2)+'\n');} +console.log(JSON.stringify(report.summary,null,2)); +process.exitCode=report.samples.some(s=>s.status!=='ok')?1:0; diff --git a/aesel/bin/speed-tasks.mjs b/aesel/bin/speed-tasks.mjs new file mode 100644 index 0000000000..4591b2c094 --- /dev/null +++ b/aesel/bin/speed-tasks.mjs @@ -0,0 +1,28 @@ +import {runInNewContext} from 'node:vm'; +export const tasks={ + ping:{prompt:'Reply with exactly: SABLE',check:text=>text.trim()==='SABLE'}, + sequence:{prompt:'List the integers 1 through 40, separated by comma and space. No other text.',check:text=>text.trim()===Array.from({length:40},(_,i)=>i+1).join(', ')}, + coding:{prompt:'Return only a JSON object with one key, "code". Its value must be a JavaScript function declaration named mergeRanges(ranges). Input is an array of inclusive integer pairs [start,end], sometimes reversed and unsorted. Return sorted disjoint normalized pairs, merging overlapping OR adjacent ranges, preserving the input without mutation. Empty input returns []. Include no imports, markdown, tools, tests, or explanation.',check:text=>{ + try{ + const object=JSON.parse(text); + if(typeof object.code!=='string'||object.code.length>16000)return false; + // No host objects/functions enter the guest realm. Dynamic code generation + // is disabled and every sample is bounded. Only a boolean leaves the realm. + return runInNewContext(`${object.code}\n(()=>{ + const cases=[ + [[],[]], + [[[5,2]],[[2,5]]], + [[[8,10],[1,3],[4,7]],[[1,10]]], + [[[1,2],[4,5]],[[1,2],[4,5]]], + [[[3,3],[3,3],[2,1]],[[1,3]]], + [[[-1,-5],[-7,-6],[0,2],[10,9]],[[-7,2],[9,10]]], + [[[1,10],[3,4],[12,12],[11,11]],[[1,12]]] + ]; + return cases.every(([input,expected])=>{ + const before=JSON.stringify(input),actual=mergeRanges(input); + return JSON.stringify(actual)===JSON.stringify(expected)&&JSON.stringify(input)===before; + }); + })()`,Object.create(null),{timeout:100,contextCodeGeneration:{strings:false,wasm:false}})===true; + }catch{return false;} + }} +}; diff --git a/aesel/package.json b/aesel/package.json index 508b201be5..1fe5cb2513 100644 --- a/aesel/package.json +++ b/aesel/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "build:bun": "bun bin/build-bun.mjs", + "bench:providers": "node bin/bench-providers.mjs", "bench:runtimes": "python3 bin/bench-runtimes.py --entry launch --launcher --runs 7 --out ../tmp/tui-latency/runtimes.json", "test": "node bin/sync-context.mjs --check && node --test test/*.test.mjs && bash test/cli.sh", "context": "node bin/sync-context.mjs", diff --git a/aesel/src/ac-server.mjs b/aesel/src/ac-server.mjs index 6f987d518b..16bd74424d 100644 --- a/aesel/src/ac-server.mjs +++ b/aesel/src/ac-server.mjs @@ -1,7 +1,7 @@ import {EDIT_PIECE,applyPieceEdits} from './piece-edits.mjs'; import {withNetworkDeadline, isTransientNetworkError} from "./network.mjs"; import {bundledContext} from './piece-context.mjs'; -import {PIECE_VISUAL,PIECE_RESPONSIVE,PIECE_CLOCK,PIECE_SOUND} from './piece-prompt.mjs'; +import {PIECE_VISUAL,PIECE_RESPONSIVE,PIECE_CLOCK,PIECE_SOUND,PIECE_REPLY} from './piece-prompt.mjs'; import {SETTINGS_TOOL,PIECE_INSTRUCTIONS} from './harness-contract.mjs'; import {captureFrame,FRAME_TOOL} from "./preview-frame.mjs"; // The Aesthetic Computer bridge — inference without a vendor CLI. @@ -207,6 +207,9 @@ export class AcServer extends EventEmitter { if (this.developerInstructions) { blocks.push({ type: "text", text: this.developerInstructions }); } + // The GUI's direct AC session supplies no developer instructions. Give it + // the same prose contract as the TUI and native Codex/Claude bridges. + if (!this.developerInstructions.includes(PIECE_REPLY)) blocks.push({type:'text',text:PIECE_REPLY}); if (this.artifactContext) blocks.push({type:'text',text:this.artifactContext}); if (!this.artifactContext && this.piece?.file && existsSync(this.piece.file)) { blocks.push({ type: "text", text: `Current piece (${this.piece.file})${this.layeredEdits ? '; revision '+this.revisionForSource(readFileSync(this.piece.file,'utf8')) : ''}; preserve the user's existing work unless asked to change it:\n\n${readFileSync(this.piece.file, "utf8")}` }); diff --git a/aesel/src/piece-prompt.mjs b/aesel/src/piece-prompt.mjs index bd1cc2f17c..d65ac4c35c 100644 --- a/aesel/src/piece-prompt.mjs +++ b/aesel/src/piece-prompt.mjs @@ -8,4 +8,4 @@ export const PIECE_CLOCK = "When adding sound, default to the shared network clo export const PIECE_SOUND = "Named sounds (roar, bray, bark, wind, bird) are a pitch contour plus a noise layer plus an envelope, never a fixed-pitch oscillator stab: build them with slide, vibrato, noise, formant and lowpass on sound.synth, or with the organic generators sound.howl, sound.growl, sound.breath and sound.chirp. Two or three layered voices at most; see the Sound section of the piece guide."; -export const PIECE_REPLY = "Default to one short sentence, usually under 25 words, about the visible result. During an actionable request, give one short public progress line before the first edit, then another only when the concrete approach changes or new evidence matters. Keep each line around 6–14 words, in first person, about the particular object and action in this request. Be playful when it fits: “I'm giving those wheels a little swagger” or “I'm untangling that roof overlap.” These are examples of tone, not stock phrases to repeat. Say what you are about to try, not that it has already worked. Read the existing source before describing an edit whose details you do not know yet. Stream the progress line as ordinary public text, then call the tool in the same response; do not pause for acknowledgement. No generic acknowledgements, task restatements, step lists, or hidden reasoning. Intermediate lines live only in the output stream; finish with a separate brief reply about the supported result. Questions about Aesel itself or an explicit request for discussion may be answered in chat; ordinary piece prompts must produce code and a visible result. Use plain, warm language. Speak in first person as the donkey, with natural contractions (for example, “I made the circle smaller”). Your intermediate public words stream along the horizon beneath the donkey; final replies belong in the notebook. Do not wrap them in parentheses or narrate the donkey in third person. Describe only actions and results supported by the current tool evidence; do not invent progress or claim a visual check you have not made. Do not summarize the request, list changes, announce generic success, narrate routine tool mechanics, or end with an offer. Ask one gentle question only when it helps the user explore or make a necessary choice. Never add a question just to sound Socratic. Expand only when the user asks for an explanation or essential evidence requires it. Omit routine URLs, commits, hashes, file paths, tool names, tests, and publishing details. Report material failures, limitations, costs, and required consent honestly and briefly. When discussing a tunable color or numeric constant, use its exact source color literal or constant identifier, preferably as inline code, so the notebook can bind it to an editor. Mention only values useful to the request; do not dump a palette or parameter list. Aesel renders Markdown tables, LaTeX math in $...$ or $$...$$, mermaid fenced diagrams, and static svg fenced vector figures. Prefer concise mathematical notation (for example ×, →, θ, fractions, or a short equation), a small diagram, or a meaningful symbol when it explains an idea more directly than words. Define unfamiliar symbols briefly. Do not add decorative icons or longer explanations just to exercise the renderer. Use one compact visual when requested or when it explains more clearly than prose; do not add decorative headings or restate the visual. This is rich chat rendering, not a full LaTeX document compiler. Do not output image URLs or HTML for figures."; +export const PIECE_REPLY = "Aesel is terse. For English prose, default to one short sentence under 25 words: the answer or visible result, then stop. This also applies to questions, explanations, and conversation about Aesel. Use two short sentences only when needed for a material limitation or necessary clarification. Give longer prose only when the user explicitly asks for detail. This brevity rule applies only to prose: code replies, fenced code blocks, complete source files, diffs, and tool arguments may be as long as needed. Never truncate code, replace required code with ellipses or placeholders, or shorten implementation or verification to meet a prose word limit. During an actionable request, give one short public progress line before the first edit, then another only when the concrete approach changes or new evidence matters. Keep each line around 6–14 words, in first person, about the particular object and action in this request. Be playful when it fits: “I'm giving those wheels a little swagger” or “I'm untangling that roof overlap.” These are examples of tone, not stock phrases to repeat. Say what you are about to try, not that it has already worked. Read the existing source before describing an edit whose details you do not know yet. Stream the progress line as ordinary public text, then call the tool in the same response; do not pause for acknowledgement. No generic acknowledgements, task restatements, step lists, or hidden reasoning. Intermediate lines live only in the output stream; finish with a separate brief reply about the supported result. Questions about Aesel itself or an explicit request for discussion may be answered in chat; ordinary piece prompts must produce code and a visible result. Use plain, warm language. Speak in first person as the donkey, with natural contractions (for example, “I made the circle smaller”). Your intermediate public words stream along the horizon beneath the donkey; final replies belong in the notebook. Do not wrap them in parentheses or narrate the donkey in third person. Describe only actions and results supported by the current tool evidence; do not invent progress or claim a visual check you have not made. Do not summarize the request, list changes, announce generic success, narrate routine tool mechanics, or end with an offer. Ask one gentle question only when it helps the user explore or make a necessary choice. Never add a question just to sound Socratic. An explanation request still gets a short explanation unless the user asks for depth; include essential evidence compactly. Omit routine URLs, commits, hashes, file paths, tool names, tests, and publishing details. Report material failures, limitations, costs, and required consent honestly and briefly. When discussing a tunable color or numeric constant, use its exact source color literal or constant identifier, preferably as inline code, so the notebook can bind it to an editor. Mention only values useful to the request; do not dump a palette or parameter list. Aesel renders Markdown tables, LaTeX math in $...$ or $$...$$, mermaid fenced diagrams, and static svg fenced vector figures. Prefer concise mathematical notation (for example ×, →, θ, fractions, or a short equation), a small diagram, or a meaningful symbol when it explains an idea more directly than words. Define unfamiliar symbols briefly. Do not add decorative icons or longer explanations just to exercise the renderer. Use one compact visual when requested or when it explains more clearly than prose; do not add decorative headings or restate the visual. This is rich chat rendering, not a full LaTeX document compiler. Do not output image URLs or HTML for figures."; diff --git a/aesel/src/render.mjs b/aesel/src/render.mjs index 49f7b50d78..3882479bf5 100644 --- a/aesel/src/render.mjs +++ b/aesel/src/render.mjs @@ -1327,7 +1327,7 @@ function spendText(state, width) { } const backend = providerSettings?.backend; const account = backend === "codex" ? codexUsageText(codexRateLimits) - : backend === "ac" && Number.isFinite(braincells) ? `${compact(Math.max(0, Math.round(braincells)))} braincells` : ""; + : backend === "ac" && Number.isFinite(braincells) ? `${compact(Math.max(0, Math.round(braincells)))} 🧠` : ""; if (account) { // Keep the account meter when the terminal cannot fit both counters. if (textWidth([...parts, account].join(" · ")) > width) return account; diff --git a/aesel/test/ac-server.test.mjs b/aesel/test/ac-server.test.mjs index 70f89c6bd3..06a02ad6a7 100644 --- a/aesel/test/ac-server.test.mjs +++ b/aesel/test/ac-server.test.mjs @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { AcServer } from "../src/ac-server.mjs"; +import { PIECE_REPLY } from "../src/piece-prompt.mjs"; // A stand-in for the endpoint: hands back whatever SSE the test wants, so the // loop can be driven through paths a live server would be slow or costly to @@ -165,6 +166,20 @@ test("the guides travel in the prompt, because this bridge has no file tools", a ); }); +test("direct GUI AC requests carry the shared reply contract without duplicating caller instructions", async () => { + for (const developerInstructions of ["", PIECE_REPLY]) { + let sent; + const engine = new AcServer({ developerInstructions, token: async () => "tok", fetch: async (_url, options) => { + sent = JSON.parse(options.body); + return serving(say("Done."))(); + }}); + await engine.connect(); + await engine.startTurn("hi"); + const prompt = sent.system.map(block => block.text).join("\n"); + assert.equal(prompt.split(PIECE_REPLY).length - 1, 1); + } +}); + test("a completed piece checkpoint saves before the response ends", async (t) => { const dir = await mkdtemp(join(tmpdir(), "ac-checkpoint-")); t.after(() => rm(dir, { recursive: true, force: true })); diff --git a/aesel/test/render.test.mjs b/aesel/test/render.test.mjs index dd3b02c1af..9baa075d31 100644 --- a/aesel/test/render.test.mjs +++ b/aesel/test/render.test.mjs @@ -16,8 +16,8 @@ test("the footer meters the active provider and fits Codex usage in a narrow ter assert.match(line, /12\.3k tok/); if (backend === "codex") assert.match(line, /codex 5h 23% · 7d 61% used/); else assert.doesNotMatch(line, /codex.*used/); - if (backend === "ac") assert.match(line, /500\.0k braincells/); - else assert.doesNotMatch(line, /braincells/); + if (backend === "ac") assert.match(line, /500\.0k 🧠/); + else assert.doesNotMatch(line, /🧠/); } const codex = { ...base, providerSettings: { backend: "codex" } }; for (const width of [32, 60, 100]) { diff --git a/aesel/test/speed-tasks.test.mjs b/aesel/test/speed-tasks.test.mjs new file mode 100644 index 0000000000..fbdda8bb62 --- /dev/null +++ b/aesel/test/speed-tasks.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {tasks} from '../bin/speed-tasks.mjs'; +const result=code=>JSON.stringify({code}); +test('coding benchmark checks reversal, adjacency, ordering, and input preservation',()=>{ + assert.equal(tasks.coding.check(result(`function mergeRanges(ranges){ + const sorted=ranges.map(([a,b])=>[Math.min(a,b),Math.max(a,b)]).sort((a,b)=>a[0]-b[0]); + const out=[]; for(const pair of sorted){const last=out.at(-1);if(last&&pair[0]<=last[1]+1)last[1]=Math.max(last[1],pair[1]);else out.push(pair);}return out; + }`)),true); + assert.equal(tasks.coding.check(result('function mergeRanges(ranges){return ranges.sort((a,b)=>a[0]-b[0]);}')),false); + assert.equal(tasks.coding.check('```js\nfunction mergeRanges(){}\n```'),false); + assert.equal(tasks.coding.check(result('function mergeRanges(){while(true){}}')),false); + assert.equal(tasks.coding.check(result('function mergeRanges(){return process.env;}')),false); +}); +test('fixed replies must match fully; a partial or verbose reply is not a fast success',()=>{ + assert.equal(tasks.ping.check(' SABLE\n'),true); + assert.equal(tasks.ping.check('SABL'),false); + assert.equal(tasks.sequence.check(Array.from({length:40},(_,i)=>i+1).join(', ')),true); + assert.equal(tasks.sequence.check('1, 2, 3'),false); +}); diff --git a/apple/aesel/README.md b/apple/aesel/README.md index 52be4de4b9..434c023bb9 100644 --- a/apple/aesel/README.md +++ b/apple/aesel/README.md @@ -9,6 +9,9 @@ pieces and thread changes, and can expand to fill the workspace. The animated title and version share a fixed top strip with the Mac window controls. A blank notebook starts editing on its first ruled line; the remaining paper accepts clicks. Letter animation never changes the title's hit area. +Click the version to view providers, the reported model, the 🧠 balance and its +daily/purchased breakdown, purchases, and saved versions. Account costs stay +inside this panel rather than occupying a row above the notebook. On Mac, Command-N opens a separate notebook window. Each window keeps its own session and draft; saved threads share one store and merge per-thread updates. @@ -60,6 +63,19 @@ characters. Older runtimes remain unverified; no claim should exceed the evidenc ./run.sh mac # build and open Aesel.app ``` +To measure a built debug app without inference, run from the repository root: + +```sh +python3 apple/aesel/bin/bench-native.py --app /path/to/Debug/Aesel.app --runs 3 --out tmp/native-speed.json +``` + +This launches the empty notebook fixture and measures automation readiness, +the first state query, and verified composer edits. It refuses an existing draft +and clears its test input. Control round trips include the 150 ms mailbox timer; +they do not measure physical keystroke-to-pixel latency. The app fingerprint is +computed once, off the UI thread, when automation first requests it. Compare +provider inference separately with `aesel/bin/bench-providers.mjs --live`. + The native Mac target currently supports the shared Piece workflow: AC sign-in, hosted inference, drafts, publishing, notebook, saved threads and braincells. App Store builds use the existing identity `computer.aesthetic.easel`. diff --git a/apple/aesel/Sources/AeselAutomation.swift b/apple/aesel/Sources/AeselAutomation.swift index 6b6965b6b4..d226224623 100644 --- a/apple/aesel/Sources/AeselAutomation.swift +++ b/apple/aesel/Sources/AeselAutomation.swift @@ -23,11 +23,21 @@ final class AeselAutomation { private var sequence = 0 private var events: [[String: Any]] = [] private let instance = UUID().uuidString - private let buildSha256: String + // Fingerprinting is only needed by automation, never by opening a notebook. + // Share one lazy background task across windows instead of reading the + // entire signed app bundle synchronously on the main actor in every init. + private static var fingerprintTask: Task? - init(windowID: String = "main") { - var hash = SHA256() + private static func buildFingerprint() async -> String { + if let task = fingerprintTask { return await task.value } let bundle = Bundle.main.bundleURL + let task = Task.detached(priority: .utility) { hashBundle(bundle) } + fingerprintTask = task + return await task.value + } + + nonisolated static func hashBundle(_ bundle: URL) -> String { + var hash = SHA256() func visit(_ folder: URL) { let files = (try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey])) ?? [] for file in files.sorted(by: { $0.lastPathComponent.utf8.lexicographicallyPrecedes($1.lastPathComponent.utf8) }) { @@ -42,7 +52,10 @@ final class AeselAutomation { } } visit(bundle) - buildSha256 = hash.finalize().map { String(format: "%02x", $0) }.joined() + return hash.finalize().map { String(format: "%02x", $0) }.joined() + } + + init(windowID: String = "main") { let requested = ProcessInfo.processInfo.environment["AESEL_AUTOMATION_NAMESPACE"] ?? "" let namespace = requested.range(of: "^[a-z0-9-]{1,32}$", options: .regularExpression) != nil ? "-" + requested : "" let windowSuffix = windowID == "main" ? "" : "-window-" + windowID @@ -116,6 +129,7 @@ final class AeselAutomation { private func handle(_ method: String, _ params: [String: Any]) async throws -> [String: Any] { switch method { case "state", "map": + let buildSha256 = await Self.buildFingerprint() var state = inspect?() ?? [:] state["instance"] = instance state["buildSha256"] = buildSha256 diff --git a/apple/aesel/Sources/ContentView.swift b/apple/aesel/Sources/ContentView.swift index c679fa8124..99db346949 100644 --- a/apple/aesel/Sources/ContentView.swift +++ b/apple/aesel/Sources/ContentView.swift @@ -332,16 +332,6 @@ struct ContentView: View { private var sheet: some View { VStack(spacing: 0) { notebookHeader - HStack(spacing: 12) { - Button { openSettings() } label: { - Text(session.braincells.map { $0.formatted(.number.precision(.fractionLength(0))) + " braincells" } ?? session.creditsStatus) - } - Spacer(minLength: 8) - Text(session.reportedModel.isEmpty ? (session.provider == "ac" ? "AC · automatic model" : session.model) : session.reportedModel) - .lineLimit(1).truncationMode(.middle) - } - .font(Paint.font(12)).foregroundStyle(paint.dim) - .buttonStyle(AeselButtonStyle()).padding(.horizontal, edgeInset).padding(.vertical, 4) GeometryReader { geometry in ScrollViewReader { proxy in ScrollView { @@ -669,6 +659,10 @@ struct ContentView: View { settingsField("Model") { AeselModelPicker(session: session, host: host) } } } + if !session.reportedModel.isEmpty { + Text(session.reportedModel).font(Paint.font(13)).foregroundStyle(paint.dim) + .textSelection(.enabled) + } if !session.providerNotice.isEmpty { Text(session.providerNotice).font(Paint.font(13)).foregroundStyle(paint.dim) } @@ -695,6 +689,36 @@ struct ContentView: View { settingsItem("Sign in to Aesthetic Computer") { closeSettings { host.signIn() } } } if !session.accountNotice.isEmpty { Text(session.accountNotice).font(Paint.font(13)) } + if session.signedIn { + Rectangle().fill(paint.ink.opacity(0.16)).frame(height: 1) + VStack(alignment: .leading, spacing: 8) { + if let balance = session.braincells { + HStack(spacing: 8) { + Text("🧠").accessibilityHidden(true) + Text(balance.formatted(.number.precision(.fractionLength(0)))) + } + .font(Paint.font(24)) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(balance.formatted(.number.precision(.fractionLength(0)))) braincells remaining") + if let dollars = session.braincellDollars { + Text("\(usd(dollars)) equivalent").font(Paint.font(13)).foregroundStyle(paint.dim) + } + if let daily = session.freeDollars, let purchased = session.purchasedDollars { + Text("Daily \(usd(daily)) · Purchased \(usd(purchased))") + .font(Paint.font(13)).foregroundStyle(paint.dim) + } + } else { + Text(session.creditsStatus).foregroundStyle(paint.dim) + } + buyButton + if !braincells.storeStatus.isEmpty { + Text(braincells.storeStatus).font(Paint.font(13)).foregroundStyle(paint.dim) + } + if !braincells.notice.isEmpty { + Text(braincells.notice).font(Paint.font(13)) + } + } + } Rectangle().fill(paint.ink.opacity(0.16)).frame(height: 1) AeselVersionList(session: session, host: host, onSelect: { closeSettings() }) }.padding(.horizontal, 20).padding(.bottom, 20) @@ -715,9 +739,9 @@ struct ContentView: View { Task { await braincells.buy() } } label: { HStack(spacing: 10) { - Image("braincell").resizable().scaledToFit().frame(width: 28, height: 28) + Text("🧠").font(.system(size: 28)).accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { - Text(braincells.busy ? "Opening the App Store…" : "Add 1,000,000 braincells") + Text(braincells.busy ? "Opening the App Store…" : "Add 1,000,000") .font(Paint.font(18)).fontWeight(.semibold) Text(braincells.busy ? "" : "\(braincells.price) · App Store").font(Paint.font(13)).opacity(0.85) } diff --git a/apple/aesel/Tests/AutomationFingerprintChecks.swift b/apple/aesel/Tests/AutomationFingerprintChecks.swift new file mode 100644 index 0000000000..bdc85b5154 --- /dev/null +++ b/apple/aesel/Tests/AutomationFingerprintChecks.swift @@ -0,0 +1,20 @@ +import Foundation +@main struct AutomationFingerprintChecks { + static func main() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root.appendingPathComponent("Contents/_CodeSignature"), withIntermediateDirectories: true) + let source = root.appendingPathComponent("Contents/source") + try Data("one".utf8).write(to: source) + let original = AeselAutomation.hashBundle(root) + precondition(original.count == 64) + try Data("signature".utf8).write(to: root.appendingPathComponent("Contents/_CodeSignature/CodeResources")) + precondition(AeselAutomation.hashBundle(root) == original, "Signing must not change source identity") + try Data("two".utf8).write(to: source) + precondition(AeselAutomation.hashBundle(root) != original, "Changed contents must change identity") + try Data("one".utf8).write(to: source) + try FileManager.default.moveItem(at: source, to: root.appendingPathComponent("Contents/renamed")) + precondition(AeselAutomation.hashBundle(root) != original, "Paths are part of source identity") + print("Automation bundle fingerprint checks passed") + } +} diff --git a/apple/aesel/bin/bench-native.py b/apple/aesel/bin/bench-native.py new file mode 100644 index 0000000000..6212d7060c --- /dev/null +++ b/apple/aesel/bin/bench-native.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Measure the real native app with its debug notebook fixture, without inference. + +Process -> automation ready and same-user control round trips are separate from +physical paint latency. The 150 ms mailbox timer is included in control timing. +""" +import argparse,json,os,pathlib,plistlib,shutil,statistics,subprocess,tempfile,time,uuid +p=argparse.ArgumentParser(description=__doc__) +p.add_argument('--app',type=pathlib.Path,required=True) +p.add_argument('--runs',type=int,default=3) +p.add_argument('--out',type=pathlib.Path,required=True) +a=p.parse_args() +if not 1<=a.runs<=10:p.error('runs must be 1–10') +app=a.app.resolve();info=plistlib.loads((app/'Contents/Info.plist').read_bytes());identifier=info['CFBundleIdentifier'] +if identifier!='computer.aesthetic.aesel.native':p.error('Use the debug native bundle, with AESEL_NOTEBOOK_PREVIEW support') +samples=[] +for run in range(a.runs): + namespace='speed-'+uuid.uuid4().hex[:8] + env={**os.environ,'AESEL_AUTOMATION_NAMESPACE':namespace,'AESEL_NOTEBOOK_PREVIEW':'1','AESEL_PREVIEW_EMPTY':'1'} + roots=[pathlib.Path.home()/'Library/Application Support'/identifier/('automation-'+namespace),pathlib.Path.home()/'Library/Containers'/identifier/'Data/Library/Application Support'/identifier/('automation-'+namespace)] + sample={'run':run+1,'status':'failed'} + with tempfile.TemporaryFile() as log: + started=time.monotonic();child=subprocess.Popen([str(app/'Contents/MacOS'/info['CFBundleExecutable'])],env=env,stdout=log,stderr=log) + try: + root=None + while time.monotonic()-started<20: + for candidate in roots: + try: + instance=json.loads((candidate/'instance.json').read_text()) + if instance['pid']==child.pid:root=candidate;break + except (FileNotFoundError,json.JSONDecodeError):pass + if root:break + if child.poll() is not None:raise RuntimeError('Native app exited') + time.sleep(.005) + if not root:raise RuntimeError('Automation not ready within 20s') + sample['automationReadyMs']=(time.monotonic()-started)*1000 + def request(method,params={}): + key=str(uuid.uuid4()).upper();path=root/'requests'/(key+'.json');response=root/'responses'/(key+'.json') + payload={'id':key,'instance':instance['instance'],'createdAt':time.time(),'method':method,'params':params} + pending=path.with_suffix('.tmp');pending.write_text(json.dumps(payload));pending.replace(path) + deadline=time.monotonic()+10 + try: + while time.monotonic()= Self.releaseSeconds } + var tail = voice + tail.releasedAt = now + // Freeze this value: resetting a recycled channel's expression must + // not tell the automixer that its old release became a fresh voice. + tail.releaseLevel = voice.level * expression[Int(voice.channel)] + releases.append(tail) + } + + func setExpression(_ value: UInt8, channel: UInt8) { + guard channel < 16 else { return } + lock.lock(); defer { lock.unlock() } + expression[Int(channel)] = Float(value) / 127 + } + + /// Fast attenuation on an incoming voice; 250 ms recovery avoids gain + /// jumps when a channel is recycled or a group of notes is released. + func nextGain(at now: TimeInterval) -> Float { + lock.lock(); defer { lock.unlock() } + let elapsed = max(0, now - (lastUpdate ?? now)) + lastUpdate = now + releases.removeAll { now - ($0.releasedAt ?? now) >= Self.releaseSeconds } + var pitches = [Float](repeating: 0, count: 128) + for voice in voices.values { + pitches[Int(voice.note & 0x7F)] += voice.level * expression[Int(voice.channel)] + } + for voice in releases { + let remaining = max(0, 1 - (now - voice.releasedAt!) / Self.releaseSeconds) + pitches[Int(voice.note & 0x7F)] += voice.releaseLevel * Float(remaining) + } + let power = pitches.reduce(Float(0)) { $0 + $1 * $1 } + // Four full-level copies of one pitch reserve 3 dB. A four-note + // chord reserves 1.5 dB. Full inverse-amplitude normalization would + // over-duck phase-cancelling repeats and quiet instrument patches. + // Leave the remaining dynamics to the measured compressor/limiter. + let target = max(0.5, pow(max(1, power), -0.125)) + if target < gain { gain = target } + else { gain += (target - gain) * Float(1 - exp(-elapsed / 0.25)) } + if target == 1, gain > 0.999 { gain = 1 } + return gain + } + + var isIdle: Bool { + lock.lock(); defer { lock.unlock() } + return voices.isEmpty && releases.isEmpty && gain == 1 + } + + func reset() { + lock.lock(); defer { lock.unlock() } + voices.removeAll(keepingCapacity: true) + releases.removeAll(keepingCapacity: true) + expression = [Float](repeating: 1, count: 16) + gain = 1 + lastUpdate = nil + } +} diff --git a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift index 1490dba45c..e6137a3020 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift @@ -383,7 +383,7 @@ final class MenuBandSynth { private var configChangeObserver: NSObjectProtocol? /// Master output gain on the pre-limiter sum bus, 0.0…1.0. Applied - /// to `preLimiterMixer.outputVolume` so every backend (MIDISynth, + /// to `postFxMixer.outputVolume` so every backend (MIDISynth, /// sampler, radio, sample voice, plugin) scales together. Sitting /// BEFORE the limiter means lowering the slider also pulls the /// peak-limit threshold down proportionally — the slider is the @@ -395,6 +395,8 @@ final class MenuBandSynth { private var melodicDuckTarget: Float = 1 private var melodicDuckHoldUntil: CFTimeInterval = 0 private var melodicDuckTimer: Timer? + private let melodicHeadroom = MelodicHeadroom() + private var melodicHeadroomTimer: Timer? /// External callers (the controller's hover-preview path) read this /// to decide whether they need to wait for the bank-swap settle delay @@ -2275,6 +2277,28 @@ final class MenuBandSynth { tonesFader.outputVolume = max(0, min(1, value)) } + /// Reserve headroom before dry/wet summing and compression, independently + /// of the user's tones trim and the percussion sidechain. Mixer parameter + /// ramps smooth attenuation; the model supplies a slower gain recovery. + private func updateMelodicHeadroom() { + let update = { [weak self] in + guard let self, self.started else { return } + self.preLimiterMixer.outputVolume = self.melodicHeadroom.nextGain(at: CACurrentMediaTime()) + guard self.melodicHeadroomTimer == nil else { return } + let timer = Timer(timeInterval: 1.0 / 120, repeats: true) { [weak self] timer in + guard let self, self.started else { timer.invalidate(); return } + self.preLimiterMixer.outputVolume = self.melodicHeadroom.nextGain(at: CACurrentMediaTime()) + if self.melodicHeadroom.isIdle { + timer.invalidate() + self.melodicHeadroomTimer = nil + } + } + self.melodicHeadroomTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + if Thread.isMainThread { update() } else { DispatchQueue.main.async(execute: update) } + } + /// The monitor is audible only while Menu Band has performance focus: /// defocus (Esc) mutes it, the Command gesture / focus shortcut brings it /// back. The graph stays wired and the tape keeps its dry stem. @@ -2756,6 +2780,10 @@ final class MenuBandSynth { // ours-but-idle, and Music.app etc. tend to give up retrying. releaseHogModeIfNeeded() engine.stop() + melodicHeadroomTimer?.invalidate() + melodicHeadroomTimer = nil + melodicHeadroom.reset() + preLimiterMixer.outputVolume = 1 // Unwire the duplex input before the engine goes away so the private // aggregate is torn down deliberately, not by process exit — a // dangling input registration wedges the Scarlett for every client. @@ -2972,6 +3000,8 @@ final class MenuBandSynth { // first note so it can't sound as the default sine. reassertMIDISynthBankIfDirty() activeNotes.insert(noteKey(midi, channel: channel)) + melodicHeadroom.noteOn(midi, velocity: velocity, channel: channel, at: CACurrentMediaTime()) + updateMelodicHeadroom() // Plugin instrument wins on melodic — picked deliberately via the // About → Plugins picker, so it overrides every other melodic // backend. Drums (ch 9) still flow to GM below. @@ -3130,7 +3160,14 @@ final class MenuBandSynth { /// audibly over the tail instead of holding at full volume and /// then snapping silent at the cleanup noteOff. func sendExpression(value: UInt8, channel: UInt8 = 0) { - guard started, midiSynthReady, let au = midiSynth?.audioUnit else { return } + guard started else { return } + // These backends do not receive this CC; don't release their + // headroom as if their voices had audibly faded. + if !usingPluginInstrument && !usingFluoddityVoice && !gmRoutable(currentMelodicProgram), + midiSynthReady || usingSampleBackend { + melodicHeadroom.setExpression(value, channel: channel) + } + guard midiSynthReady, let au = midiSynth?.audioUnit else { return } sendMIDIEvent(au, status: 0xB0 | (channel & 0x0F), data1: 11, data2: value & 0x7F) } @@ -3154,6 +3191,7 @@ final class MenuBandSynth { DMXOut.shared.noteOff(midi: Int(midi)) let key = noteKey(midi, channel: channel) activeNotes.remove(key) + melodicHeadroom.noteOff(midi, channel: channel, at: CACurrentMediaTime()) defer { scheduleIdleSuspendIfNeeded() } // Release an AC GM voice if this exact note was routed there on // press — independent of the current toggle / program so it can't @@ -3198,6 +3236,8 @@ final class MenuBandSynth { func panic() { guard started else { return } DMXOut.shared.blackout() + melodicHeadroom.reset() + preLimiterMixer.outputVolume = 1 activeNotes.removeAll() gmRoutedNotes.removeAll() if gmSynthEnabled { gmSynth.panic() } diff --git a/slab/menuband/bin/check-melodic-headroom.sh b/slab/menuband/bin/check-melodic-headroom.sh new file mode 100644 index 0000000000..fa9ac759a4 --- /dev/null +++ b/slab/menuband/bin/check-melodic-headroom.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Offline audio regression; optional argument saves before/after whistle WAVs. +set -euo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT +nice -n 8 xcrun swiftc -O -suppress-warnings -parse-as-library \ + "$root/Sources/MenuBand/MelodicHeadroom.swift" \ + "$root/bin/check-melodic-headroom.swift" -o "$scratch/checks" +"$scratch/checks" "$@" diff --git a/slab/menuband/bin/check-melodic-headroom.swift b/slab/menuband/bin/check-melodic-headroom.swift new file mode 100644 index 0000000000..91c6c2fe93 --- /dev/null +++ b/slab/menuband/bin/check-melodic-headroom.swift @@ -0,0 +1,178 @@ +import AVFoundation +import AudioToolbox + +/// Render Apple's real DLS voices without opening an audio device. The event +/// sequence matches sustained linger: four rotating channels, 100 ms CC11 +/// fade ticks, note-off before resetting expression on channel reuse. +@main +enum MelodicHeadroomChecks { + struct Measurement { + var peak: Float = 0 + var power = 0.0 + var frames = 0 + var rmsDB: Double { 10 * log10(max(1e-20, power / Double(max(1, frames)))) } + } + + static func check(_ status: OSStatus, line: Int = #line) { + precondition(status == noErr, "AudioUnit status \(status) at line \(line)") + } + + static func effect(_ type: OSType) -> AVAudioUnitEffect { + AVAudioUnitEffect(audioComponentDescription: AudioComponentDescription( + componentType: kAudioUnitType_Effect, componentSubType: type, + componentManufacturer: kAudioUnitManufacturer_Apple, + componentFlags: 0, componentFlagsMask: 0)) + } + + static func render(program: UInt32, automix: Bool, repeated: Bool, + save: URL? = nil) throws -> Measurement { + let headroom = MelodicHeadroom() + let engine = AVAudioEngine() + let synth = AVAudioUnitMIDIInstrument(audioComponentDescription: AudioComponentDescription( + componentType: kAudioUnitType_MusicDevice, componentSubType: kAudioUnitSubType_MIDISynth, + componentManufacturer: kAudioUnitManufacturer_Apple, + componentFlags: 0, componentFlagsMask: 0)) + let mixer = AVAudioMixerNode() + let compressor = effect(kAudioUnitSubType_DynamicsProcessor) + let limiter = effect(kAudioUnitSubType_PeakLimiter) + let format = AVAudioFormat(standardFormatWithSampleRate: 48_000, channels: 2)! + for node in [synth, mixer, compressor, limiter] as [AVAudioNode] { engine.attach(node) } + engine.connect(synth, to: mixer, format: format) + engine.connect(mixer, to: compressor, format: format) + engine.connect(compressor, to: limiter, format: format) + engine.connect(limiter, to: engine.mainMixerNode, format: format) + // Match MenuBandSynth's existing downstream dynamics; the production + // headroom model above is the only difference between A and B. + for (id, value): (AudioUnitParameterID, Float) in [ + (kDynamicsProcessorParam_Threshold, -18), (kDynamicsProcessorParam_HeadRoom, 8), + (kDynamicsProcessorParam_AttackTime, 0.008), (kDynamicsProcessorParam_ReleaseTime, 0.18), + (kDynamicsProcessorParam_OverallGain, 1.5), + ] { check(AudioUnitSetParameter(compressor.audioUnit, id, kAudioUnitScope_Global, 0, value, 0)) } + for (id, value): (AudioUnitParameterID, Float) in [ + (kLimiterParam_AttackTime, 0.002), (kLimiterParam_DecayTime, 0.05), (kLimiterParam_PreGain, 0), + ] { check(AudioUnitSetParameter(limiter.audioUnit, id, kAudioUnitScope_Global, 0, value, 0)) } + var bank = URL(fileURLWithPath: + "/System/Library/Components/CoreAudio.component/Contents/Resources/gs_instruments.dls") as CFURL + check(AudioUnitSetProperty(synth.audioUnit, kMusicDeviceProperty_SoundBankURL, + kAudioUnitScope_Global, 0, &bank, UInt32(MemoryLayout.size))) + var now = 0.0 + func midi(_ status: UInt32, _ a: UInt32, _ b: UInt32 = 0) { + let channel = UInt8(status & 15) + switch status & 0xf0 { + case 0x90: headroom.noteOn(UInt8(a), velocity: UInt8(b), channel: channel, at: now) + case 0x80: headroom.noteOff(UInt8(a), channel: channel, at: now) + case 0xb0: if a == 11 { headroom.setExpression(UInt8(b), channel: channel) } + default: break + } + check(MusicDeviceMIDIEvent(synth.audioUnit, status, a, b, 0)) + } + func selectProgram() { + for channel: UInt32 in 0..<4 { + midi(0xB0 | channel, 0, 0x79) + midi(0xB0 | channel, 32) + midi(0xC0 | channel, program) + } + } + try engine.enableManualRenderingMode(.offline, format: format, maximumFrameCount: 480) + engine.prepare() + var preload: UInt32 = 1 + check(AudioUnitSetProperty(synth.audioUnit, kAUMIDISynthProperty_EnablePreload, + kAudioUnitScope_Global, 0, &preload, 4)) + selectProgram() + preload = 0 + check(AudioUnitSetProperty(synth.audioUnit, kAUMIDISynthProperty_EnablePreload, + kAudioUnitScope_Global, 0, &preload, 4)) + try engine.start() + defer { engine.stop() } + selectProgram() // Starting resets channel programs; do not test the fallback sine. + let file = try save.map { try AVAudioFile(forWriting: $0, settings: format.settings) } + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 480)! + var lastPress = [Int](repeating: -10_000, count: 4) + var result = Measurement() + for tick in 0..<1700 { + now = Double(tick) / 100 + if repeated ? (tick >= 100 && tick < 600 && tick % 15 == 0) : tick == 105 { + let channel = (tick / 15) % 4 + if lastPress[channel] >= 0 { midi(0x80 | UInt32(channel), 72) } + midi(0xB0 | UInt32(channel), 11, 127) + midi(0x90 | UInt32(channel), 72, 100) + lastPress[channel] = tick + } + for channel in 0..<4 where lastPress[channel] >= 0 { + let age = tick - lastPress[channel] + if age >= 5 && (age - 5) % 10 == 0 { + let value = max(0, 127 - Int((Double(age - 5) / 1000 * 127).rounded())) + midi(0xB0 | UInt32(channel), 11, UInt32(value)) + } + if age == 1025 { midi(0x80 | UInt32(channel), 72) } + } + if automix { mixer.outputVolume = headroom.nextGain(at: now) } + let status = try engine.renderOffline(480, to: buffer) + precondition(status == .success, "offline render failed") + try file?.write(from: buffer) + for channel in 0..<2 { + for frame in 0..= 200 && tick < 600 { + result.power += Double(x * x) + result.frames += 1 + } + } + } + } + precondition(result.peak < 1 && result.peak > 0.001, "clipped or silent render") + return result + } + + static func checkLifecycle() { + let mix = MelodicHeadroom() + mix.noteOn(72, velocity: 100, channel: 0, at: 0) + mix.noteOn(36, velocity: 127, channel: 9, at: 0) + precondition(mix.nextGain(at: 0) == 1, "solo/drums unnecessarily attenuated") + for channel: UInt8 in 1..<4 { mix.noteOn(72, velocity: 100, channel: channel, at: 0) } + let stacked = mix.nextGain(at: 0) + precondition(stacked > 0.7 && stacked < 0.72, "four repeats need about 3 dB of headroom") + for channel: UInt8 in 0..<4 { mix.setExpression(0, channel: channel) } + let fading = mix.nextGain(at: 0.01) + precondition(fading > stacked && fading < stacked + 0.02, "gain recovery jumped") + precondition(mix.nextGain(at: 3) == 1, "silent linger reserved headroom") + mix.reset() + precondition(mix.isIdle, "reset retained voices or attenuation") + mix.noteOn(72, velocity: 100, channel: 0, at: 4) + mix.noteOff(72, channel: 0, at: 4.5) + mix.noteOn(72, velocity: 100, channel: 0, at: 4.5) + precondition(mix.nextGain(at: 4.5) < 0.9, "channel reuse lost its release tail") + mix.noteOff(72, channel: 0, at: 4.6) + precondition(!mix.isIdle, "released voice discarded too early") + precondition(mix.nextGain(at: 8) == 1 && mix.isIdle, "release failed to recover to idle") + mix.reset() + for channel: UInt8 in 0..<4 { mix.noteOn(72, velocity: 20, channel: channel, at: 9) } + precondition(mix.nextGain(at: 9) == 1, "quiet repeats lost their dynamics") + mix.reset() + for channel: UInt8 in 0..<4 { mix.noteOn(60 + channel * 4, velocity: 100, channel: channel, at: 10) } + precondition(mix.nextGain(at: 10) > stacked, "chords treated as coherent copies") + print("PASS voice lifecycle: drums, quiet notes, chords, expression, release, channel reuse, reset") + } + + static func main() throws { + checkLifecycle() + let directory = CommandLine.arguments.dropFirst().first.map { URL(fileURLWithPath: $0) } + if let directory { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) } + for program: UInt32 in [16, 48, 56, 78, 79, 88] { + let before = try render(program: program, automix: false, repeated: true, + save: program == 78 ? directory?.appendingPathComponent("whistle-before.wav") : nil) + let after = try render(program: program, automix: true, repeated: true, + save: program == 78 ? directory?.appendingPathComponent("whistle-after.wav") : nil) + let reduction = before.rmsDB - after.rmsDB + precondition(reduction > 0.5 && reduction < 6, "repeats unbalanced: \(program), \(reduction) dB") + print(String(format: "PASS instrument %d: repeated-note RMS %.2f → %.2f dBFS; peak %.2f → %.2f dBFS", + program + 1, before.rmsDB, after.rmsDB, 20 * log10(before.peak), 20 * log10(after.peak))) + } + let soloBefore = try render(program: 78, automix: false, repeated: false) + let soloAfter = try render(program: 78, automix: true, repeated: false) + precondition(abs(soloBefore.rmsDB - soloAfter.rmsDB) < 0.01, "solo dynamics changed") + print("PASS solo whistle level preserved") + } +} diff --git a/slab/menubar-swift/README.md b/slab/menubar-swift/README.md index 9c8712049d..3c7fef5c0e 100644 --- a/slab/menubar-swift/README.md +++ b/slab/menubar-swift/README.md @@ -49,6 +49,11 @@ Double-tap Control over a window to zoom; repeat to exit. Command–Option–arr focuses the neighboring prompt and smoothly refits the lens, including across Deskflow hosts. Move the pointer deliberately to resume mouse following. Full-height windows keep the lens active at 1×. Reduce Motion skips animation. +Escape immediately clears zoom on this Mac and every reachable Slab peer, +even with stuck modifiers or the lens preference off. It also cancels queued +Control taps and delayed zoom handoffs; the foreground app still receives Escape. +Slab resets leftover compositor zoom at launch. The tailnet ledger exposes +`POST /zoom/reset` for recovery and `GET /zoom` to verify the actual factor. Holding Command–Option outlines the current pane and shows one arrow on each destination reachable with the next keypress. Zoom entry and arrow jumps retain their edge flash and particle burst. @@ -281,17 +286,17 @@ competing release compiles. During iteration, use the cheaper debug-only path: ./build-dev.sh ``` -Run `./install.sh` once when the change is ready to install. -On Macs using the sandboxed Tailscale app, installation also provisions a -user-local CLI wrapper so the fleet ledger can discover and bind its tailnet IP. - -Loopboy's iMessage watcher also needs one macOS privacy grant for the stable To see a change running, `./dev.sh` builds debug and hands the binary to `install.sh` (`SLAB_PREBUILT`), which signs it with the stable identity and relaunches the agent in place; `./dev.sh --watch` repeats that on every save under `Sources/` (fswatch). A one-file change is live in seconds. The live menubar is then the debug build until `./install.sh` restores release. +Run `./install.sh` once when the change is ready to install. +On Macs using the sandboxed Tailscale app, installation also provisions a +user-local CLI wrapper so the fleet ledger can discover and bind its tailnet IP. + +Loopboy's iMessage watcher also needs one macOS privacy grant for the stable signed app at `~/Applications/SlabMenubar.app`: System Settings → Privacy & Security → Full Disk Access. The menu and logs report an authorization error instead of emitting a false quiet heartbeat when that grant is absent. The diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index 42625161b3..f6b84070d7 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -407,6 +407,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // window follows and reframes it; ⌃⌃ again zooms back out. // The tap listens always — the flag is checked at fire time, not here, so // toggling the feature from the menu doesn't need to tear a tap down. + // Clear compositor zoom left behind by a crash or forced upgrade. + ZoomLens.zoomOut() let lensTap = CtrlDoubleTap( onDoubleTap: { [weak self] in guard let self = self, self.state.zoomLens else { return } @@ -415,7 +417,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { onPointerMove: { [weak self] point in guard let self = self, self.state.zoomLens else { return } ZoomLens.followCursor(to: point) - }) + }, + onEscape: { ZoomEscape.cancelFleet() }) + ZoomEscape.cancelPendingInput = { [weak lensTap] in lensTap?.cancelPendingInput() } if lensTap.start() { zoomLensTap = lensTap } // setDesktopImageURL only writes the wallpaper on the active Space of diff --git a/slab/menubar-swift/Sources/SlabMenubar/CtrlDoubleTap.swift b/slab/menubar-swift/Sources/SlabMenubar/CtrlDoubleTap.swift index 40dfa48170..df16f576d4 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/CtrlDoubleTap.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/CtrlDoubleTap.swift @@ -31,16 +31,21 @@ final class CtrlDoubleTap { private let onDoubleTap: () -> Void private let onPointerMove: (CGPoint) -> Void + private let onEscape: () -> Void private var tap: CFMachPort? private var source: CFRunLoopSource? private var lastTapAt: CFTimeInterval = 0 private var pendingPointerLocation: CGPoint? private var pointerDeliveryScheduled = false + private var controlDown = false + private var generation: UInt64 = 0 init(onDoubleTap: @escaping () -> Void, - onPointerMove: @escaping (CGPoint) -> Void = { _ in }) { + onPointerMove: @escaping (CGPoint) -> Void = { _ in }, + onEscape: @escaping () -> Void = {}) { self.onDoubleTap = onDoubleTap self.onPointerMove = onPointerMove + self.onEscape = onEscape } /// Returns false if the tap couldn't be created — which in practice always @@ -97,18 +102,36 @@ final class CtrlDoubleTap { } func stop() { + cancelPendingInput() if let src = source { CFRunLoopRemoveSource(CFRunLoopGetMain(), src, .commonModes) } if let port = tap { CGEvent.tapEnable(tap: port, enable: false) } source = nil tap = nil } + func cancelPendingInput() { + lastTapAt = 0 + controlDown = false + pendingPointerLocation = nil + generation &+= 1 + } + private func handle(type: CGEventType, event: CGEvent) { if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + cancelPendingInput() + onEscape() if let port = tap { CGEvent.tapEnable(tap: port, enable: true) } return } + // Escape is an emergency exit even with stuck Deskflow modifiers or + // the lens preference off. Listen-only leaves the app's Escape intact. + if type == .keyDown && event.getIntegerValueField(.keyboardEventKeycode) == 53 { + cancelPendingInput() + if event.getIntegerValueField(.keyboardEventAutorepeat) == 0 { onEscape() } + return + } + if type == .mouseMoved || type == .leftMouseDragged || type == .rightMouseDragged || type == .otherMouseDragged { queuePointerMove(event.location) @@ -134,7 +157,13 @@ final class CtrlDoubleTap { // .maskControl is set on the press edge and cleared on release; we only // want the press. - guard event.flags.contains(.maskControl) else { return } + guard event.flags.contains(.maskControl) else { + controlDown = false + return + } + // Deskflow can duplicate press flags; a release must separate taps. + guard !controlDown else { return } + controlDown = true // Bare ⌃ only. A ⌃⌘ press is a chord in progress, and letting it arm a // tap would make the *next* bare ⌃ fire the lens. @@ -148,7 +177,11 @@ final class CtrlDoubleTap { let now = CACurrentMediaTime() if now - lastTapAt <= Self.window { lastTapAt = 0 - DispatchQueue.main.async { [weak self] in self?.onDoubleTap() } + let queuedGeneration = generation + DispatchQueue.main.async { [weak self] in + guard let self, self.generation == queuedGeneration else { return } + self.onDoubleTap() + } } else { lastTapAt = now } diff --git a/slab/menubar-swift/Sources/SlabMenubar/DeskflowSpatialNav.swift b/slab/menubar-swift/Sources/SlabMenubar/DeskflowSpatialNav.swift index 106cca6f0b..942bb49d74 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/DeskflowSpatialNav.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/DeskflowSpatialNav.swift @@ -65,6 +65,7 @@ enum DeskflowSpatialNav { let carryZoom = ZoomLens.isEngaged let zoomRevision = ZoomLens.revision + let startedAt = Date().timeIntervalSince1970 DispatchQueue.global(qos: .userInteractive).async { // The controller acknowledges as soon as its edge moves are queued. // Queue the destination immediately too, with only enough delay for @@ -76,6 +77,7 @@ enum DeskflowSpatialNav { "alignment": Double(alignment), "delayMs": max(10, (path.count - 1) * 28 + 10), "zoom": carryZoom, + "startedAt": startedAt, ]) if accepted && carryZoom { DispatchQueue.main.async { @@ -94,8 +96,14 @@ enum DeskflowSpatialNav { let alignment = CGFloat((body["alignment"] as? NSNumber)?.doubleValue ?? 0.5) let zoom = body["zoom"] as? Bool ?? false let delay = min(max((body["delayMs"] as? NSNumber)?.doubleValue ?? 0, 0), 250) / 1000 - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - WindowNav.accept(direction, alignment: alignment, zoom: zoom) + DispatchQueue.main.async { + let revision = ZoomEscape.revision + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + let canZoom = ZoomEscape.allowsRemoteZoom( + startedAt: (body["startedAt"] as? NSNumber)?.doubleValue, + revision: revision) + WindowNav.accept(direction, alignment: alignment, zoom: zoom && canZoom) + } } return true } diff --git a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift index 9753d5dc0f..e585f745a7 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift @@ -152,6 +152,13 @@ final class LedgerStore { s?.onLaunch = { body in Self.launchPrompt(body) } s?.onNavigate = { body in DeskflowSpatialNav.receiveNavigate(body) } s?.onDeskflowRoute = { body in DeskflowSpatialNav.receiveRoute(body) } + s?.onZoomReset = { + DispatchQueue.main.sync { + ZoomEscape.cancelLocal() + return ZoomLens.status + } + } + s?.onZoomStatus = { DispatchQueue.main.sync { ZoomLens.status } } server = s } @@ -622,6 +629,8 @@ final class LedgerHTTPServer { var onNavigate: (([String: Any]) -> Bool)? /// Ask the active Deskflow controller to traverse a validated direction path. var onDeskflowRoute: (([String: Any]) -> Bool)? + var onZoomReset: (() -> [String: Any])? + var onZoomStatus: (() -> [String: Any])? private var fd: Int32 = -1 private let queue = DispatchQueue(label: "slab.ledger.http") private var running = false @@ -762,6 +771,20 @@ final class LedgerHTTPServer { return } + // A reset is terminal: never rebroadcast received requests, or peers + // would echo Escape forever. Reply only after the compositor was reset. + let request = line.split(separator: " ") + if request.count >= 2, + (request[0] == "POST" && request[1] == "/zoom/reset" + || request[0] == "GET" && request[1] == "/zoom") { + let result = request[0] == "POST" ? onZoomReset?() : onZoomStatus?() + var object = result ?? [:] + object["ok"] = result != nil + respond(client, body: (try? JSONSerialization.data(withJSONObject: object)) + ?? Data("{\"ok\":false}".utf8)) + return + } + // Anything else (GET /ledger) → the current local ledger JSON. let body = (try? Data(contentsOf: URL(fileURLWithPath: LedgerStore.localFile))) ?? Data("{\"host\":\"\",\"entries\":[]}".utf8) diff --git a/slab/menubar-swift/Sources/SlabMenubar/ZoomEscape.swift b/slab/menubar-swift/Sources/SlabMenubar/ZoomEscape.swift new file mode 100644 index 0000000000..dd7e06a99d --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/ZoomEscape.swift @@ -0,0 +1,59 @@ +import AppKit +import Foundation + +/// Escape travels over Slab's tailnet ledger, independently of which machine +/// Deskflow currently gives the keyboard. All entry points run on main. +enum ZoomEscape { + static var cancelPendingInput: (() -> Void)? + private(set) static var revision: UInt64 = 0 + private static var cancelledAt = Date.distantPast + private static var lastBroadcast = Date.distantPast + + static func cancelLocal() { + revision &+= 1 + cancelledAt = Date() + cancelPendingInput?() + ZoomLens.zoomOut() // no animation that can race the next pointer event + } + + /// Also rejects a handoff already in flight when Escape was pressed. + static func allowsRemoteZoom(startedAt: Double?, revision expected: UInt64) -> Bool { + guard revision == expected, Date().timeIntervalSince(cancelledAt) > 3 else { return false } + if let startedAt { return startedAt > cancelledAt.timeIntervalSince1970 } + return true + } + + static func cancelFleet() { + cancelLocal() + // Deskflow may deliver a physical Escape to both source and target; + // key repeat and tap recovery must not flood the peer listeners. + guard Date().timeIntervalSince(lastBroadcast) > 0.25 else { return } + lastBroadcast = Date() + DispatchQueue.global(qos: .userInitiated).async { + let fm = FileManager.default + let names = (try? fm.contentsOfDirectory(atPath: LedgerStore.peersDir)) ?? [] + var addresses = Set() + for name in names where name.hasSuffix(".json") { + guard let data = fm.contents(atPath: "\(LedgerStore.peersDir)/\(name)"), + let peer = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let ip = peer["ip"] as? String, !ip.isEmpty else { continue } + addresses.insert(ip) + } + // Do not filter by prompt count, Deskflow role, or cache freshness: + // an idle/reconnected machine can still have stuck compositor zoom. + for ip in addresses { + guard let url = URL(string: "http://\(ip):\(LedgerStore.port)/zoom/reset") else { continue } + var request = URLRequest(url: url, timeoutInterval: 2) + request.httpMethod = "POST" + request.httpBody = Data("{}".utf8) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + URLSession.shared.dataTask(with: request) { data, _, error in + let result = data.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + if error != nil || result?["ok"] as? Bool != true { + NSLog("slab zoom escape: peer %@ did not acknowledge reset", ip) + } + }.resume() + } + } + } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/ZoomLens.swift b/slab/menubar-swift/Sources/SlabMenubar/ZoomLens.swift index 89f3e79237..adbb68817a 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/ZoomLens.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ZoomLens.swift @@ -46,8 +46,8 @@ import CoreGraphics /// wasn't engaged by CloseView itself. We set the origin directly instead. /// /// The zoom is compositor state, so it OUTLIVES this process: quit while zoomed -/// and the screen stays zoomed. Hence `zoomOut()` on terminate. (A user is never -/// truly stuck — ⌃+scroll still gets them out — but they shouldn't have to.) +/// and the screen stays zoomed. Reset on launch and termination; Escape also +/// resets every reachable Slab peer through the ledger, independent of Deskflow. enum ZoomLens { /// How much of the screen to leave around the window. 1.1 → the window fills /// ~90% of the screen's tighter axis, and the 10% left over is the context @@ -82,6 +82,10 @@ enum ZoomLens { static var isZoomed: Bool { current().factor > zoomedThreshold } static var isEngaged: Bool { activeTarget != nil } static var targetWindowID: CGWindowID? { activeTarget?.id } + static var status: [String: Any] { + ["factor": Double(current().factor), "engaged": isEngaged, + "animating": panTimer != nil] + } /// Keyboard navigation owns the camera until the pointer deliberately moves. @discardableResult diff --git a/slab/menubar-swift/tests/zoom-escape.sh b/slab/menubar-swift/tests/zoom-escape.sh new file mode 100644 index 0000000000..46af71209c --- /dev/null +++ b/slab/menubar-swift/tests/zoom-escape.sh @@ -0,0 +1,86 @@ +#!/bin/sh +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +probeDir=$(mktemp -d /tmp/slab-zoom-escape.XXXXXX) +trap 'rm -rf "$probeDir"' EXIT +cat "$root/Sources/SlabMenubar/CtrlDoubleTap.swift" > "$probeDir/check.swift" +cat >> "$probeDir/check.swift" <<'SWIFT' +extension CtrlDoubleTap { + static func checkEscape() { + var toggles = 0 + var escapes = 0 + var moves = 0 + let detector = CtrlDoubleTap(onDoubleTap: { toggles += 1 }, + onPointerMove: { _ in moves += 1 }, onEscape: { escapes += 1 }) + let event = CGEvent(keyboardEventSource: nil, virtualKey: 59, keyDown: true)! + func control(_ down: Bool) { + event.setIntegerValueField(.keyboardEventKeycode, value: 59) + event.flags = down ? .maskControl : [] + detector.handle(type: .flagsChanged, event: event) + } + func drain() { RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.02)) } + control(true); control(true); drain() + assert(toggles == 0, "duplicate Deskflow press must not zoom") + control(false); control(true); drain() + assert(toggles == 1, "real double Control must still work") + control(false) + control(true); control(false); control(true) + detector.queuePointerMove(.zero) + // Escape arrives before the scheduled toggle/follow callbacks. + event.setIntegerValueField(.keyboardEventKeycode, value: 53) + event.flags = [.maskControl, .maskCommand, .maskAlternate] + detector.handle(type: .keyDown, event: event) + drain() + assert(escapes == 1 && toggles == 1 && moves == 0) + event.setIntegerValueField(.keyboardEventAutorepeat, value: 1) + detector.handle(type: .keyDown, event: event) + assert(escapes == 1, "held Escape must not flood peers") + event.setIntegerValueField(.keyboardEventAutorepeat, value: 0) + control(false); control(true); control(false); control(true) + detector.handle(type: .tapDisabledByTimeout, event: event) + drain() + assert(escapes == 2 && toggles == 1, "disabled tap must cancel queued zoom") + control(false); control(true); control(false); control(true) + detector.stop(); drain() + assert(toggles == 1, "stopped listener must not execute queued toggle") + print("Control / Escape event-order checks passed") + } +} +CtrlDoubleTap.checkEscape() +SWIFT +swift "$probeDir/check.swift" +cat "$root/Sources/SlabMenubar/ZoomEscape.swift" > "$probeDir/fleet.swift" +cat >> "$probeDir/fleet.swift" <<'SWIFT' +enum ZoomLens { + static var resets = 0 + static func zoomOut() { resets += 1 } +} +enum LedgerStore { + static let peersDir = "/nonexistent-slab-test-peers" + static let port: UInt16 = 5252 +} +extension ZoomEscape { + static func checkCancellation() { + var cancelledInput = 0 + cancelPendingInput = { cancelledInput += 1 } + let original = revision + let oldHandoff = Date().timeIntervalSince1970 + assert(allowsRemoteZoom(startedAt: oldHandoff, revision: original)) + cancelLocal() + assert(ZoomLens.resets == 1 && cancelledInput == 1) + assert(!allowsRemoteZoom(startedAt: oldHandoff, revision: original)) + assert(!allowsRemoteZoom(startedAt: nil, revision: revision)) + // Simulate cooldown expiry without a sleep. A delayed pre-Escape + // packet still cannot reactivate zoom, even after the cooldown. + cancelledAt = Date(timeIntervalSinceNow: -4) + assert(!allowsRemoteZoom(startedAt: Date().timeIntervalSince1970 - 5, revision: revision)) + assert(allowsRemoteZoom(startedAt: Date().timeIntervalSince1970, revision: revision)) + cancelFleet() + cancelFleet() + assert(ZoomLens.resets == 3, "broadcast debounce must never skip local reset") + print("Fleet cancellation / stale handoff checks passed") + } +} +ZoomEscape.checkCancellation() +SWIFT +swift "$probeDir/fleet.swift" diff --git a/slab/menubar-swift/tests/zoom-fleet-broadcast.sh b/slab/menubar-swift/tests/zoom-fleet-broadcast.sh new file mode 100644 index 0000000000..ff872844f8 --- /dev/null +++ b/slab/menubar-swift/tests/zoom-fleet-broadcast.sh @@ -0,0 +1,45 @@ +#!/bin/sh +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +python3 - "$root" <<'PY' +import http.server, json, pathlib, subprocess, sys, tempfile, threading +requests = [] +class Peer(http.server.BaseHTTPRequestHandler): + def do_POST(self): + requests.append((self.path, self.rfile.read(int(self.headers['Content-Length'])))) + data = b'{"ok":true,"factor":1,"engaged":false,"animating":false}' + self.send_response(200) + self.send_header('Content-Length',str(len(data))) + self.end_headers() + self.wfile.write(data) + def log_message(self, *_): pass +server = http.server.ThreadingHTTPServer(('127.0.0.1',0), Peer) +threading.Thread(target=server.serve_forever,daemon=True).start() +try: + with tempfile.TemporaryDirectory(prefix='slab-zoom-fleet-') as temp: + root = pathlib.Path(temp) + peers = root / 'peers' + peers.mkdir() + # Idle stale peers still need reset. Duplicate cached IPs get one POST. + for name in ['idle','duplicate']: + (peers / (name+'.json')).write_text(json.dumps({'ip':'127.0.0.1','updatedAt':0,'entries':[]})) + (peers / 'broken.json').write_text('not json') + source = (pathlib.Path(sys.argv[1])/'Sources/SlabMenubar/ZoomEscape.swift').read_text() + source += ''' +enum ZoomLens { static func zoomOut() {} } +enum LedgerStore { + static let peersDir = %s + static let port: UInt16 = %s +} +ZoomEscape.cancelFleet() +ZoomEscape.cancelFleet() +RunLoop.main.run(until: Date(timeIntervalSinceNow: 2)) +''' % (json.dumps(str(peers)), server.server_address[1]) + script = root/'check.swift' + script.write_text(source) + subprocess.run(['swift',str(script)],check=True) + assert requests == [('/zoom/reset', b'{}')], requests + print('Fleet HTTP broadcast / idle peers / duplicate suppression checks passed') +finally: + server.shutdown() +PY